diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 0cfc796be..8cb464b19 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -64,9 +64,29 @@ jobs:
run: |
# The full .diff endpoint rejects large-but-valid PRs at 20k lines.
# The paginated files endpoint remains filename-only for this gate.
- FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename')
+ if FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files?per_page=100" --jq '.[].filename'); then
+ echo "file list: pulls/files API"
+ else
+ # The files endpoint renders the diff server-side too, and answers
+ # 422 "this diff is taking too long to generate" for a PR that
+ # regenerates a vendored parser (#2246: a 42 MB sql/parser.c) --
+ # so does the compare endpoint. The PR merge ref's first parent is
+ # the base, so a name-only diff across it is the same file list.
+ # depth 2 + blob:none fetches commits and trees only (well under
+ # 1 MB, under a second); nothing from the PR is checked out or run.
+ echo "::notice::pulls/files API failed; file list taken from refs/pull/$PR/merge"
+ CHANGES_GIT="$RUNNER_TEMP/changes.git"
+ git init -q --bare "$CHANGES_GIT"
+ git --git-dir="$CHANGES_GIT" fetch -q --depth=2 --filter=blob:none \
+ "https://github.com/$REPO" "refs/pull/$PR/merge"
+ FILES=$(git --git-dir="$CHANGES_GIT" diff --name-only --no-renames FETCH_HEAD^1 FETCH_HEAD)
+ fi
printf '%s\n' "$FILES"
- if printf '%s\n' "$FILES" | grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)'; then
+ # A here-string, not a pipe: under this shell's pipefail, grep -q
+ # exits on its first match, printf dies of SIGPIPE once the list
+ # outgrows the pipe buffer, and the MATCH is then reported as a
+ # failure -- product=false, smoke skipped, on the largest PRs.
+ if grep -qE '^(src/|internal/|install\.(sh|ps1)|scripts/build\.sh|scripts/smoke-test\.sh|scripts/smoke-local\.sh|scripts/smoke-fixture-server\.py|scripts/gen-third-party-notices\.sh|scripts/env\.sh|test-infrastructure/vm/(vm-smoke\.sh|windows-user-path-guard\.ps1)|Makefile\.cbm)' <<<"$FILES"; then
echo "product=true" >> "$GITHUB_OUTPUT"
else
echo "product=false" >> "$GITHUB_OUTPUT"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 96ce8f861..93596d06f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,7 +55,7 @@ src/
foundation/ Arena allocator, hash table, string utils, platform compat
store/ SQLite graph storage (WAL mode, FTS5)
cypher/ Cypher query → SQL translation
- mcp/ MCP server (JSON-RPC 2.0 over stdio, 14 tools)
+ mcp/ MCP server (JSON-RPC 2.0 over stdio, 17 tools)
pipeline/ Multi-pass indexing pipeline
pass_*.c Individual pipeline passes (definitions, calls, usages, etc.)
httplink.c HTTP route extraction (Go/Express/Laravel/Ktor/Python)
@@ -149,6 +149,25 @@ If in doubt, open an issue and ask.
- Run `scripts/test.sh` and `scripts/lint.sh` before submitting
- Keep PRs focused — avoid unrelated reformatting or refactoring
+## AI-assisted and AI-authored contributions
+
+Contributions written with the help of an AI tool, or written by an AI agent acting for a person, are welcome here on the same terms as any other. What we review is the change, not who or what typed it. Three things make that work:
+
+- **Say so.** If an agent wrote the PR or is answering in the thread, disclose it — a line in the PR description or at the top of a comment is enough ("written with X", "this account is operated by an AI agent on behalf of Y"). Disclosure is never held against a contribution. Finding out later is.
+- **A person is accountable.** The [DCO sign-off](#license-and-sign-off-dco--required-on-every-commit) is a legal certification, and only a person can make it. The human behind the account certifies every commit, answers for it, and is who we talk to if something goes wrong. An agent may carry the conversation; it cannot carry the responsibility.
+- **The bar does not move.** Every claim in a PR must be something a reviewer can check: the failing test, the command that was run, the numbers and the machine they came from. "The model says it works" is not evidence, and a confident description does not replace a test that fails without the fix. We read AI-written PRs exactly as carefully as human-written ones — no more suspiciously, and no less.
+
+Two practical notes:
+
+- `Co-authored-by:` trailers naming a tool are fine. Links to private chat or agent sessions are not — they are dead to everyone else and do not belong in permanent history; we will ask you to remove them.
+- High-volume, low-effort submissions — many near-identical PRs, changes nobody ran, replies that do not engage with the review — will be closed, whoever or whatever produced them. One careful PR is worth more than twenty generated ones.
+
+### Who speaks for the project
+
+Only the maintainer account ([@DeusData](https://github.com/DeusData)) speaks for this project. A review, an approval, a "this will be merged" or a "this is a duplicate" from any other account is that person's opinion, however official it sounds — helpful reviews from the community are very welcome, and that is what they are.
+
+**Impersonating a maintainer is not acceptable**, by a person or by an agent: presenting yourself as part of the maintainer team, posting approvals or change requests styled as maintainer decisions, or telling other contributors what the project has decided. We dismiss such reviews, hide the comments, say publicly in the affected threads that they were not ours, and report and block the account. If you are unsure whether a response you received is from the project, check the account name — and ask.
+
## Security
We take security seriously. All PRs go through:
diff --git a/README.md b/README.md
index 9d9e4ee09..66b50fe08 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a native executable with a small verified runtime-asset set for macOS, Linux, and Windows — download, run `install`, done.
-High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 162 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. No language runtime, hosted service, or API key. Plug and play across 45 supported automatic/conditional client surfaces.
+High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 162 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 17 MCP tools. No language runtime, hosted service, or API key. Plug and play across 45 supported automatic/conditional client surfaces.
> **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration.
@@ -37,7 +37,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si
- **45 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself.
- **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources.
-- **15 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
+- **17 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
## Quick Start
@@ -461,7 +461,7 @@ Add to `~/.claude.json` (user scope) or project `.mcp.json`:
}
```
-Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 15 tools.
+Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp` with 17 tools.
@@ -673,6 +673,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| `list_projects` | List all indexed projects with node/edge counts. |
| `delete_project` | Remove a project and all its graph data. |
| `index_status` | Check indexing status of a project. |
+| `check_index_coverage` | Check whether exact paths or a scope are indexed and fresh. A clean result means no recorded gap, not proof of completeness. |
### Querying
@@ -683,7 +684,9 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| `detect_changes` | Map git diff to affected symbols + blast radius with risk classification. |
| `query_graph` | Execute Cypher-like graph queries (read-only). |
| `get_graph_schema` | Node/edge counts, relationship patterns, property definitions per label. Run this first. |
+| `compare_graphs` | Compare two indexed snapshots: node/edge additions and removals between a base and a target. |
| `get_code_snippet` | Read source code for a function by qualified name. |
+| `get_file_outline` | Declaration outline of one repository-relative file in source order, with optional label filter and paging. |
| `get_architecture` | Codebase overview: languages, packages, routes, hotspots, clusters, ADR. |
| `search_code` | Grep-like text search within indexed project files. |
| `manage_adr` | CRUD for Architecture Decision Records (`get` reads, `update` replaces the whole document, `set_sections` rewrites only the named sections and leaves every other byte untouched, `sections` lists headings). Query modes do not wait behind a same-project reindex; writes remain serialized. |
@@ -849,7 +852,7 @@ Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, A
src/
main.c Entry point (MCP stdio server + CLI + install/update/config)
daemon/ Per-account session coordination, IPC, lifecycle, shared jobs/watchers
- mcp/ MCP server (15 tools, JSON-RPC 2.0, session detection, auto-index)
+ mcp/ MCP server (17 tools, JSON-RPC 2.0, session detection, auto-index)
cli/ Install/uninstall/update/config (45 client surfaces, hooks, instructions)
store/ SQLite graph storage (nodes, edges, traversal, search, Louvain)
pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → config → tests)
diff --git a/docs/index.html b/docs/index.html
index 56496d5d8..fdfde941e 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -56,7 +56,7 @@
"featureList": [
"Indexes 158 programming languages via vendored tree-sitter grammars",
"Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust",
- "15 MCP tools for structural search, call-path tracing, targeted coverage checks, and Cypher graph queries",
+ "17 MCP tools for structural search, call-path tracing, targeted coverage checks, and Cypher graph queries",
"Semantic vector code search via bundled nomic-embed-code embeddings (no API key, fully local)",
"Semantic graph edges (SEMANTICALLY_RELATED) and near-clone detection (SIMILAR_TO, MinHash + LSH)",
"Cross-service linking for HTTP, gRPC, GraphQL, tRPC, and pub/sub channels with confidence scoring",
@@ -739,8 +739,8 @@
The optional UI variant adds one verified, content-addressed frontend pack and serves an interactive 3D graph at localhost:9749 to explore nodes, edges, and clusters visually.
-
15 MCP tools
-
search_graph, trace_path, detect_changes, query_graph (Cypher), get_architecture, get_code_snippet, check_index_coverage, manage_adr, and 7 more.
+
17 MCP tools
+
search_graph, trace_path, detect_changes, query_graph (Cypher), get_architecture, get_code_snippet, check_index_coverage, manage_adr, and 9 more.
Cypher graph queries
diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c
index 51c14eca1..b54fb54a7 100644
--- a/internal/cbm/cbm.c
+++ b/internal/cbm/cbm.c
@@ -125,7 +125,79 @@ void cbm_reset_profile(void) {
} \
} while (0)
+/* A definition name never spans lines. Config extractors hand over a node's
+ * whole text — a YAML block key, a Makefile recipe (`endif\n\n$(obj)/x.h`), a
+ * ktest .conf directive — and the line break rode into the graph (2026-09-16
+ * probe: 8,301 Field nodes on elastic/elasticsearch, 91 kernel nodes). Cut at
+ * the first line break and drop trailing blanks; the qualified name carries
+ * the same text as its last segment and is cut the same way. */
+static const char *cbm_first_line(CBMArena *a, const char *text) {
+ if (!text) {
+ return text;
+ }
+ const char *nl = strpbrk(text, "\r\n");
+ if (!nl) {
+ return text;
+ }
+ size_t n = (size_t)(nl - text);
+ while (n > 0 && (text[n - 1] == ' ' || text[n - 1] == '\t')) {
+ n--;
+ }
+ char *cut = (char *)cbm_arena_alloc(a, n + 1);
+ if (!cut) {
+ return text;
+ }
+ memcpy(cut, text, n);
+ cut[n] = '\0';
+ return cut;
+}
+
+/* JS/TS files: a definition named by a literal token is the walker naming a
+ * function or variable from an object key or a numeric pattern element —
+ * `{}` (1,534 incoming CALLS on microsoft/TypeScript's test baselines), `1`
+ * (38 nodes), a quoted string. An identifier starts with a letter, `_`, `$`,
+ * `#` (private members) or a non-ASCII byte; anything else is not a name. */
+static bool cbm_js_family_path(const char *path) {
+ if (!path) {
+ return false;
+ }
+ const char *dot = strrchr(path, '.');
+ if (!dot) {
+ return false;
+ }
+ static const char *const exts[] = {".js", ".mjs", ".cjs", ".jsx", ".ts",
+ ".mts", ".cts", ".tsx", ".ets", NULL};
+ for (int i = 0; exts[i]; i++) {
+ if (strcmp(dot, exts[i]) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool cbm_js_name_is_junk(const char *name) {
+ if (!name || !name[0]) {
+ return false; /* empty names are handled by the callers' own rules */
+ }
+ unsigned char c = (unsigned char)name[0];
+ bool identifier_start = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' ||
+ c == '$' || c == '#' || c >= 0x80;
+ /* Member names JS spells without an identifier start are still names:
+ * computed keys (`[Symbol.iterator]`), string-literal keys (`"my-key"`,
+ * `'x'`) and escaped identifiers (`А`). Measured on the TypeScript
+ * corpus: 1,531 `[…]` members, 97 quoted members, 154 escaped
+ * identifiers — all real definitions. What remains ({…} patterns, numeric
+ * literals, parenthesised types, `...rest`) is a token, not a name. */
+ bool member_key_start = c == '[' || c == '"' || c == '\'' || c == '\\';
+ return !identifier_start && !member_key_start;
+}
+
void cbm_defs_push(CBMDefArray *arr, CBMArena *a, CBMDefinition def) {
+ def.name = cbm_first_line(a, def.name);
+ def.qualified_name = cbm_first_line(a, def.qualified_name);
+ if (cbm_js_family_path(def.file_path) && cbm_js_name_is_junk(def.name)) {
+ return;
+ }
GROW_ARRAY(arr, a);
arr->items[arr->count++] = def;
}
@@ -1358,14 +1430,89 @@ static void cbm_mark_pp_error_rows(TSNode n, uint8_t *rows, uint32_t row_count,
/* Recovery subtraction (#963): tree-sitter error recovery plus the
* ERROR-descending def walker often still extract constructs INSIDE a failed
* region (verified: a function in an #ifdef-split ERROR region and even a
- * `def broken(:` both came back as defs). A region whose every line is
- * covered by definitions that START inside it is definitely recovered — its
- * constructs ARE in the graph — so flagging it would be a false miss.
- * Container defs (Module/Package) are ignored: a file-spanning Module node is
- * not evidence the region's constructs survived. Conservative: partially
- * covered regions stay flagged. */
-static bool cbm_region_is_recovered(uint32_t rs, uint32_t re, const CBMDefArray *defs) {
- enum { MAX_COVER_DEFS = 256 };
+ * `def broken(:` both came back as defs). The lines a definition that STARTS
+ * inside the region covers are therefore in the graph, and only the lines no
+ * definition covers can honestly be called missed. Container defs
+ * (Module/Package) are ignored: a file-spanning Module node is not evidence
+ * the region's constructs survived.
+ *
+ * This used to be all-or-nothing — a region stayed flagged whole unless every
+ * line was covered. On torvalds/linux (2026-09-16 probe) one ERROR node that
+ * swallowed the second half of kernel/sched/core.c (lines 5522–11284) survived
+ * because of the comment and macro lines between its 286 extracted functions,
+ * and the coverage report told a reader 68 % of the scheduler was unindexed.
+ * Now the uncovered gaps are reported instead, and a gap holding only blank,
+ * comment or preprocessor lines is not a miss at all. */
+static uint32_t *cbm_line_offsets(const char *src, int src_len, uint32_t *out_lines) {
+ uint32_t lines = 1;
+ for (int i = 0; i < src_len; i++) {
+ if (src[i] == '\n') {
+ lines++;
+ }
+ }
+ uint32_t *offs =
+ (uint32_t *)cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)(lines + 1) * sizeof(uint32_t));
+ if (!offs) {
+ *out_lines = 0;
+ return NULL;
+ }
+ uint32_t n = 0;
+ offs[n++] = 0;
+ for (int i = 0; i < src_len && n < lines; i++) {
+ if (src[i] == '\n') {
+ offs[n++] = (uint32_t)i + 1;
+ }
+ }
+ offs[n] = (uint32_t)src_len;
+ *out_lines = n;
+ return offs;
+}
+
+/* A 1-based line that holds nothing a reader would call missed code: blank,
+ * a comment line, or a preprocessor directive (the import pass owns #include;
+ * #if/#endif carry no construct of their own). */
+static bool cbm_line_is_inert(const char *src, const uint32_t *offs, uint32_t nlines,
+ uint32_t line) {
+ if (!src || !offs || line == 0 || line > nlines) {
+ return true;
+ }
+ const char *p = src + offs[line - 1];
+ const char *end = src + offs[line];
+ while (p < end && (*p == ' ' || *p == '\t' || *p == '\r')) {
+ p++;
+ }
+ if (p >= end || *p == '\n') {
+ return true;
+ }
+ if (p[0] == '/' && p + 1 < end && (p[1] == '/' || p[1] == '*')) {
+ return true;
+ }
+ return p[0] == '*' || p[0] == '#';
+}
+
+static void cbm_regions_emit_gap(cbm_error_regions_t *out, uint32_t gs, uint32_t ge,
+ const char *src, const uint32_t *offs, uint32_t nlines) {
+ bool live = false;
+ for (uint32_t l = gs; l <= ge && !live; l++) {
+ live = !cbm_line_is_inert(src, offs, nlines, l);
+ }
+ if (!live) {
+ return;
+ }
+ if (out->count < CBM_MAX_ERROR_REGIONS) {
+ out->starts[out->count] = gs;
+ out->ends[out->count] = ge;
+ out->count++;
+ } else {
+ out->dropped++;
+ }
+}
+
+/* Replace [rs, re] by the sub-ranges no in-region definition covers. */
+static void cbm_region_uncovered_gaps(uint32_t rs, uint32_t re, const CBMDefArray *defs,
+ const char *src, const uint32_t *offs, uint32_t nlines,
+ cbm_error_regions_t *out) {
+ enum { MAX_COVER_DEFS = 1024 };
uint32_t starts[MAX_COVER_DEFS];
uint32_t ends[MAX_COVER_DEFS];
int n = 0;
@@ -1377,14 +1524,25 @@ static bool cbm_region_is_recovered(uint32_t rs, uint32_t re, const CBMDefArray
if (d->start_line < rs || d->start_line > re) {
continue; /* recovery evidence must originate inside the region */
}
+ /* A one-line definition salvaged from inside an error region is the
+ * walker's guess at broken text (`def x(:` comes back as a def, a
+ * mis-parsed `static _Thread_local int x` as a Variable named `int`,
+ * and Python's synthetic builtins all sit on line 1); only a construct
+ * whose body spans lines proves it was understood. Conservative: real
+ * one-line variables and macros inside a failed region keep their line
+ * flagged. */
+ if (d->end_line <= d->start_line) {
+ continue;
+ }
starts[n] = d->start_line;
- ends[n] = d->end_line < d->start_line ? d->start_line : d->end_line;
+ ends[n] = d->end_line;
n++;
}
if (n == 0) {
- return false;
+ cbm_regions_emit_gap(out, rs, re, src, offs, nlines);
+ return;
}
- /* Insertion-sort by start, then sweep for gaps in [rs, re]. */
+ /* Insertion-sort by start, then sweep and emit the gaps in [rs, re]. */
for (int i = 1; i < n; i++) {
uint32_t s = starts[i];
uint32_t e = ends[i];
@@ -1397,16 +1555,18 @@ static bool cbm_region_is_recovered(uint32_t rs, uint32_t re, const CBMDefArray
starts[j + 1] = s;
ends[j + 1] = e;
}
- uint32_t covered_to = rs - 1;
+ uint32_t cursor = rs;
for (int i = 0; i < n; i++) {
- if (starts[i] > covered_to + 1) {
- return false; /* uncovered gap */
+ if (starts[i] > cursor) {
+ cbm_regions_emit_gap(out, cursor, starts[i] - 1, src, offs, nlines);
}
- if (ends[i] > covered_to) {
- covered_to = ends[i];
+ if (ends[i] + 1 > cursor) {
+ cursor = ends[i] + 1;
}
}
- return covered_to >= re;
+ if (cursor <= re) {
+ cbm_regions_emit_gap(out, cursor, re, src, offs, nlines);
+ }
}
/* #961: true when 1-based `line` of `src` contains `name` (used to verify a
@@ -1514,6 +1674,63 @@ static bool cbm_span_contains_callable_def(const char *src, int src_len, uint32_
return false;
}
+/* #1989: type-def counterpart of cbm_span_contains_callable_def. A rescued
+ * class/struct/enum definition has its name followed by a base clause (':') or
+ * a body ('{'), not a parameter list, so the callable validator would reject
+ * every type def pulled from the expanded tree. */
+static bool cbm_span_contains_type_def(const char *src, int src_len, uint32_t start_line,
+ uint32_t end_line, const char *name) {
+ if (!src || src_len <= 0 || !name || !name[0] || start_line == 0 || end_line < start_line) {
+ return false;
+ }
+ int span_start = 0;
+ uint32_t line = 1;
+ while (span_start < src_len && line < start_line) {
+ if (src[span_start++] == '\n') {
+ line++;
+ }
+ }
+ if (line != start_line) {
+ return false;
+ }
+ int span_end = span_start;
+ while (span_end < src_len && line <= end_line) {
+ if (src[span_end++] == '\n') {
+ line++;
+ }
+ }
+ size_t name_len = strlen(name);
+ for (int pos = span_start; pos + (int)name_len <= span_end; pos++) {
+ if (strncmp(src + pos, name, name_len) != 0 ||
+ (pos > 0 && cbm_identifier_char(src[pos - 1])) ||
+ (pos + (int)name_len < src_len && cbm_identifier_char(src[pos + name_len]))) {
+ continue;
+ }
+ int open = pos + (int)name_len;
+ while (open < span_end && isspace((unsigned char)src[open])) {
+ open++;
+ }
+ // Base clause (": public B"), body ("{"), or template args ("<...").
+ if (open < span_end && (src[open] == ':' || src[open] == '{' || src[open] == '<')) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/* #1989: label buckets for the misparse-correction gates. A raw def extracted
+ * from a broken `class MOD_API Foo` shape lands under a callable label while
+ * the expanded tree yields a proper type label for the same name. */
+static bool cbm_def_label_is_type(const char *label) {
+ return label != NULL && (strcmp(label, "Class") == 0 || strcmp(label, "Enum") == 0 ||
+ strcmp(label, "Interface") == 0 || strcmp(label, "Type") == 0 ||
+ strcmp(label, "Struct") == 0 || strcmp(label, "Union") == 0);
+}
+
+static bool cbm_def_label_is_callable(const char *label) {
+ return label != NULL && (strcmp(label, "Function") == 0 || strcmp(label, "Method") == 0);
+}
+
/* Remap an expanded-source definition back to the original input file. Every
* line in the definition must be attributable to the main file; generated
* macro bodies, included headers, and ambiguous spans fail closed. */
@@ -1541,16 +1758,19 @@ static bool cbm_remap_preprocessed_def(CBMDefinition *def, const CBMPreprocessed
return true;
}
-static void cbm_subtract_recovered_regions(cbm_error_regions_t *regs, const CBMDefArray *defs) {
- int kept = 0;
- for (int i = 0; i < regs->count; i++) {
- if (!cbm_region_is_recovered(regs->starts[i], regs->ends[i], defs)) {
- regs->starts[kept] = regs->starts[i];
- regs->ends[kept] = regs->ends[i];
- kept++;
- }
+static void cbm_subtract_recovered_regions(cbm_error_regions_t *regs, const CBMDefArray *defs,
+ const char *src, int src_len) {
+ if (regs->count <= 0) {
+ return;
}
- regs->count = kept;
+ uint32_t nlines = 0;
+ uint32_t *offs = cbm_line_offsets(src, src_len, &nlines);
+ cbm_error_regions_t out = {{0}, {0}, 0, regs->dropped};
+ for (int i = 0; i < regs->count; i++) {
+ cbm_region_uncovered_gaps(regs->starts[i], regs->ends[i], defs, src, offs, nlines, &out);
+ }
+ cbm_free(CBM_MEM_CLASS_EXTRACT, offs);
+ *regs = out;
}
/* #1071: a function-like macro invocation whose argument is a type token
@@ -1962,6 +2182,9 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
TSTree *tree = ts_parser_parse_with_options(parser, NULL, ts_input, opts);
uint64_t t1 = now_ns();
+#ifdef CBM_ENABLE_TEST_SEAMS
+ t1 += tl_parse_wall_seam_offset_ns; /* the stall seam inflates every wall reading */
+#endif
if (!tree) {
result->has_error = true;
@@ -2133,6 +2356,14 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
// Defs keep original-source line numbers; only CALLS are extracted from expanded source.
if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) {
uint64_t pp_start = now_ns();
+ /* #1989: collect build-system export-macro candidates from the raw
+ * source. They are predefined empty in the expanded buffer (see
+ * preprocessor.cpp) so `class MOD_API Foo` parses with its real name,
+ * and the rescue loop below can adopt corrected type defs. Bounded:
+ * at most CBM_EXPORT_MACRO_MAX names per file. */
+ char export_cands[CBM_EXPORT_MACRO_MAX][CBM_EXPORT_MACRO_NAME_MAX];
+ int export_cand_count =
+ cbm_export_macro_candidates(source, source_len, export_cands, CBM_EXPORT_MACRO_MAX);
CBMPreprocessedSource *preprocessed = cbm_preprocess_with_map(
source, source_len, rel_path, extra_defines, include_paths, language != CBM_LANG_C);
if (preprocessed && preprocessed->source) {
@@ -2213,17 +2444,52 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
* recover defs from it — adopting ONLY those that
* intersect a raw ERROR region, whose name is visible on
* the raw source line, and whose QN the raw pass did not
- * already extract. */
- if (ts_node_has_error(root)) {
+ * already extract.
+ *
+ * #1989: build-system export macros (
_API,
+ * _EXPORT, ...) are empty on the real compile line but
+ * opaque to tree-sitter, so `class MOD_API Foo` misparses
+ * with the macro as the type name — often with NO raw ERROR
+ * region at all (the class_specifier "succeeds" with the
+ * wrong name; enums and free functions do error out). When
+ * export-macro candidates were collected for this file, also
+ * adopt expanded defs that replace a raw def literally named
+ * as a candidate, or that correct a raw misparse label (the
+ * raw pass extracted the same name as Function/Method from
+ * the broken function_definition shape; the expanded tree
+ * yields a proper type def). Superseded raw defs are
+ * dropped so the corrected definition is not shadowed by
+ * the qualified-name dedup. With no candidates the block
+ * reduces to the original #961 behavior. */
+ if (ts_node_has_error(root) || export_cand_count > 0) {
cbm_error_regions_t raw_regs = {{0}, {0}, 0, 0};
cbm_collect_error_regions(root, &raw_regs, source, source_len);
- if (raw_regs.count > 0) {
+ if (raw_regs.count > 0 || export_cand_count > 0) {
int defs_before = result->defs.count;
cbm_extract_definitions(&pp_ctx);
int w = defs_before;
+ char *superseded = NULL;
+ if (export_cand_count > 0 && defs_before > 0) {
+ superseded =
+ (char *)cbm_calloc(CBM_MEM_CLASS_EXTRACT, (size_t)defs_before);
+ }
+ /* #1989: spans of defs already adopted from the
+ * expanded tree. A def nested inside one of these
+ * (an inline method of a rescued class) was dropped
+ * by the raw pass — the misparsed
+ * `class MOD_API Foo {...}` parsed as a
+ * function_definition, and nested defs in its body
+ * are never walked. Bounded: one span per adopted
+ * def, capped at 64. */
+ struct {
+ uint32_t start;
+ uint32_t end;
+ } rescued_spans[64];
+ int rescued_count = 0;
for (int i = defs_before; i < result->defs.count; i++) {
CBMDefinition *d = &result->defs.items[i];
bool adopt = false;
+ int supersede_j = -1;
if (cbm_remap_preprocessed_def(d, preprocessed)) {
for (int rj = 0; rj < raw_regs.count && !adopt; rj++) {
if (d->start_line <= raw_regs.ends[rj] &&
@@ -2231,26 +2497,159 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
adopt = true;
}
}
+ if (!adopt && export_cand_count > 0 && d->name) {
+ for (int ci = 0; ci < export_cand_count && !adopt; ci++) {
+ for (int j = 0; j < defs_before && !adopt; j++) {
+ CBMDefinition *r = &result->defs.items[j];
+ if (!r->name ||
+ strcmp(r->name, export_cands[ci]) != 0) {
+ continue;
+ }
+ if (r->start_line <= d->end_line &&
+ d->start_line <= r->end_line &&
+ cbm_span_contains_type_def(
+ source, source_len, d->start_line,
+ d->end_line, d->name)) {
+ adopt = true;
+ supersede_j = j;
+ }
+ }
+ }
+ if (!adopt && d->label && cbm_def_label_is_type(d->label)) {
+ for (int j = 0; j < defs_before && !adopt; j++) {
+ CBMDefinition *r = &result->defs.items[j];
+ /* Widened past callables (#1989): the
+ * misparse also mints value-label raw
+ * defs for types (`class X_API FEmpty {}`
+ * -> Variable FEmpty). Same name,
+ * overlapping span, raw label is NOT a
+ * type => raw is the misparse artifact. */
+ if (!r->name || !r->label || !d->name ||
+ strcmp(r->name, d->name) != 0 ||
+ cbm_def_label_is_type(r->label)) {
+ continue;
+ }
+ if (r->start_line <= d->end_line &&
+ d->start_line <= r->end_line) {
+ adopt = true;
+ supersede_j = j;
+ }
+ }
+ }
+ /* Nested rescue (#1989): a def nested inside
+ * an already-adopted def's span — the inline
+ * method of a rescued class, dropped by the
+ * raw pass because the class parsed as a
+ * function body. The same validation gates
+ * (name-on-line, shape, QN dedup) still run
+ * below before it lands. */
+ if (!adopt && rescued_count > 0) {
+ for (int k = 0; k < rescued_count && !adopt; k++) {
+ if (d->start_line >= rescued_spans[k].start &&
+ d->end_line <= rescued_spans[k].end) {
+ adopt = true;
+ }
+ }
+ }
+ }
}
- if (adopt && (!d->name ||
- !cbm_line_contains(source, source_len, d->start_line,
- d->name) ||
- !cbm_span_contains_callable_def(
- source, source_len, d->start_line, d->end_line,
- d->name))) {
+ if (adopt &&
+ (!d->name ||
+ !cbm_line_contains(source, source_len, d->start_line,
+ d->name) ||
+ (!cbm_span_contains_callable_def(source, source_len,
+ d->start_line, d->end_line,
+ d->name) &&
+ !cbm_span_contains_type_def(source, source_len, d->start_line,
+ d->end_line, d->name)))) {
adopt = false;
}
for (int j = 0; j < defs_before && adopt; j++) {
+ if (j == supersede_j || (superseded && superseded[j])) {
+ continue; // being replaced by this very def
+ }
const char *q = result->defs.items[j].qualified_name;
if (q && d->qualified_name &&
strcmp(q, d->qualified_name) == 0) {
+ /* #1989: a raw def holding the same QN is
+ * usually a reject — unless it is the
+ * misparse artifact this very def came to
+ * replace (raw "Function FCalc" from the
+ * broken `class MOD_API FCalc` shape vs the
+ * rescued "Class FCalc"). Same name, same
+ * span, raw label is not a type: supersede
+ * instead of dropping the correction. */
+ CBMDefinition *r = &result->defs.items[j];
+ if (d->label && cbm_def_label_is_type(d->label) &&
+ r->label && r->name && d->name &&
+ strcmp(r->name, d->name) == 0 &&
+ !cbm_def_label_is_type(r->label) &&
+ r->start_line == d->start_line &&
+ r->end_line == d->end_line && superseded) {
+ superseded[j] = 1;
+ continue;
+ }
adopt = false;
}
}
if (adopt) {
+ if (supersede_j >= 0 && supersede_j < defs_before &&
+ superseded) {
+ superseded[supersede_j] = 1;
+ }
+ /* Phantom-artifact suppression (#1989): on the
+ * raw tree `class MOD_API Foo : public Base
+ * {...}` parses as a function_definition whose
+ * declarator is the BASE-CLASS name, so the raw
+ * pass mints a bogus Function def named after
+ * the base spanning the whole class. Fingerprint:
+ * a raw callable def with an IDENTICAL span to
+ * the adopted type def whose name is one of the
+ * adopted def's base classes. A real method is
+ * a strict subset of the class span and never
+ * carries a base-class name. */
+ if (d->label && cbm_def_label_is_type(d->label) && superseded &&
+ d->base_classes) {
+ for (int j = 0; j < defs_before; j++) {
+ CBMDefinition *r = &result->defs.items[j];
+ if (!r->label || !r->name ||
+ !cbm_def_label_is_callable(r->label) ||
+ r->start_line != d->start_line ||
+ r->end_line != d->end_line) {
+ continue;
+ }
+ for (const char **b = d->base_classes; *b; b++) {
+ if (strcmp(*b, r->name) == 0) {
+ superseded[j] = 1;
+ break;
+ }
+ }
+ }
+ }
+ if (rescued_count < 64) {
+ rescued_spans[rescued_count].start = d->start_line;
+ rescued_spans[rescued_count].end = d->end_line;
+ rescued_count++;
+ }
result->defs.items[w++] = *d;
}
}
+ if (superseded) {
+ int rw = 0;
+ for (int j = 0; j < defs_before; j++) {
+ if (!superseded[j]) {
+ result->defs.items[rw++] = result->defs.items[j];
+ }
+ }
+ int shift = defs_before - rw;
+ if (shift > 0 && w > defs_before) {
+ memmove(&result->defs.items[rw],
+ &result->defs.items[defs_before],
+ (size_t)(w - defs_before) * sizeof(CBMDefinition));
+ }
+ w -= shift;
+ cbm_free(CBM_MEM_CLASS_EXTRACT, superseded);
+ }
result->defs.count = w;
}
}
@@ -2436,7 +2835,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
* splitting a brace inside a recovered function looks unrecovered:
* the refinement keeps only the discarded branch, the function starts
* above it, and the evidence falls outside the range. */
- cbm_subtract_recovered_regions(®s, &result->defs);
+ cbm_subtract_recovered_regions(®s, &result->defs, source, source_len);
/* Phase 2: cut what is left down to the lines the preprocessed parse
* could not explain. */
if (pp_line_map) {
diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h
index a7907289a..a00192a18 100644
--- a/internal/cbm/cbm.h
+++ b/internal/cbm/cbm.h
@@ -269,26 +269,31 @@ typedef struct {
* calls at 251 MB with most of that empty slots. Readers index it exactly
* as before; only `sizeof` changed. */
CBMCallArg *args;
- int arg_count; // number of captured arguments (<= CBM_MAX_CALL_ARGS)
- int loop_depth; // enclosing loop nesting at the call site
- int branch_depth; // enclosing branch nesting at the call site
- int start_line; // 1-based source line of the call (for def range-match)
- uint32_t site_start_byte; // exact AST occurrence span; end > start when present
- uint32_t site_end_byte; // exclusive byte offset in the source file
- CBMSourceOrigin source_origin; // raw source or C-family preprocessed buffer
- bool is_method; // method/member call with an UNRESOLVED receiver. Perl:
- // arrow/method call ($obj->m). TS/JS/TSX: member call
- // x.foo() whose receiver is not this/super. Python:
- // x.foo() where x is not self/cls/super() and is not
- // rooted in an imported name. Read by the weak-member
- // guard and by the pxc synthetic-carrier dedup key in
- // pass_lsp_cross.c. Default false.
- bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
- // C++ operator). Never fall back to textual resolution.
- bool callee_is_locally_bound; // bare call foo() whose callee identifier is bound as a
- // parameter of an enclosing function, so it cannot be the
- // module-level foo. Python only today. Read by the
- // weak-local-binding guard. Default false.
+ int arg_count; // number of captured arguments (<= CBM_MAX_CALL_ARGS)
+ int loop_depth; // enclosing loop nesting at the call site
+ int branch_depth; // enclosing branch nesting at the call site
+ int start_line; // 1-based source line of the call (for def range-match)
+ uint32_t site_start_byte; // exact AST occurrence span; end > start when present
+ uint32_t site_end_byte; // exclusive byte offset in the source file
+ CBMSourceOrigin source_origin; // raw source or C-family preprocessed buffer
+ bool is_method; // method/member call with an UNRESOLVED receiver. Perl:
+ // arrow/method call ($obj->m). TS/JS/TSX: member call
+ // x.foo() whose receiver is not this/super. Python:
+ // x.foo() where x is not self/cls/super() and is not
+ // rooted in an imported name. Read by the weak-member
+ // guard and by the pxc synthetic-carrier dedup key in
+ // pass_lsp_cross.c. Default false.
+ bool requires_lsp_resolution; // synthetic semantic candidate (for example an implicit
+ // C++ operator). Never fall back to textual resolution.
+ bool callee_is_locally_bound; // bare call foo() whose callee identifier is bound as a
+ // parameter of an enclosing function, so it cannot be the
+ // module-level foo. Python only today. Read by the
+ // weak-local-binding guard. Default false.
+ bool receiver_is_self_attribute; // Python member call whose receiver is an attribute
+ // chain rooted at self/cls but not self/cls itself
+ // (self.compiler.apply_converters()). An object the
+ // class owns, not a parameter: read by the weak-member
+ // guard's unique-name exemption. Default false.
} CBMCall;
typedef struct {
diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c
index 728059c71..e1de896b8 100644
--- a/internal/cbm/extract_calls.c
+++ b/internal/cbm/extract_calls.c
@@ -2068,6 +2068,14 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) {
for (uint32_t ai = 0; ai < argc && call->arg_count < CBM_MAX_CALL_ARGS; ai++) {
TSNode arg_node = ts_node_named_child(args, ai);
const char *ak = ts_node_type(arg_node);
+ /* tree-sitter lists a comment inside the argument list as a named
+ * child (Java block_comment/line_comment, C-family comment). It is
+ * not an argument: taken as one, a leading block comment became
+ * args[0] and the Route pass read its text as a URL. */
+ if (strcmp(ak, "comment") == 0 || strcmp(ak, "line_comment") == 0 ||
+ strcmp(ak, "block_comment") == 0) {
+ continue;
+ }
if (!call->args) {
call->args = cbm_arena_calloc(ctx->arena, CBM_MAX_CALL_ARGS * sizeof(CBMCallArg));
if (!call->args) {
@@ -2272,6 +2280,61 @@ static const char *extract_binary_concat_suffix(CBMExtractCtx *ctx, TSNode node)
}
// Try to extract URL/topic from a positional argument (string or constant).
+/* Swift names its call arguments, so each one is a value_argument that may lead
+ * with a value_argument_label — the `from:` in `data(from: url)`. Return the
+ * value itself, and leave any other node exactly as it came in. */
+static TSNode swift_argument_value(TSNode arg) {
+ if (strcmp(ts_node_type(arg), "value_argument") != 0 || ts_node_named_child_count(arg) == 0) {
+ return arg;
+ }
+ TSNode val = ts_node_named_child(arg, 0);
+ if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
+ ts_node_named_child_count(arg) > 1) {
+ val = ts_node_named_child(arg, 1);
+ }
+ return val;
+}
+
+/* Swift has no URL literal, so almost no real code passes a bare string to a
+ * request. It writes `URL(string: "https://…")!` instead, and the literal then
+ * sits two levels down: past the trailing `!`, which the grammar models as a
+ * postfix_expression, and inside the constructor's own argument list.
+ *
+ * Unwrap both so that literal is as reachable as a bare one. Only the three
+ * Foundation types that take a URL string are unwrapped — any other call keeps
+ * its own meaning, and a non-literal argument such as `URL(string: base + path)`
+ * falls through to the ordinary handling unchanged. */
+static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) {
+ /* Step past a trailing "!" or "?". */
+ if (strcmp(ts_node_type(arg), "postfix_expression") == 0) {
+ TSNode target = ts_node_child_by_field_name(arg, TS_FIELD("target"));
+ if (!ts_node_is_null(target)) {
+ arg = target;
+ }
+ }
+ if (strcmp(ts_node_type(arg), "call_expression") != 0) {
+ return arg;
+ }
+ TSNode callee = ts_node_named_child(arg, 0);
+ if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "simple_identifier") != 0) {
+ return arg;
+ }
+ const char *name = cbm_node_text(ctx->arena, callee, ctx->source);
+ if (!name || (strcmp(name, "URL") != 0 && strcmp(name, "URLComponents") != 0 &&
+ strcmp(name, "URLRequest") != 0)) {
+ return arg;
+ }
+ TSNode suffix = cbm_find_child_by_kind(arg, "call_suffix");
+ if (ts_node_is_null(suffix)) {
+ return arg;
+ }
+ TSNode inner = cbm_find_child_by_kind(suffix, "value_arguments");
+ if (ts_node_is_null(inner) || ts_node_named_child_count(inner) == 0) {
+ return arg;
+ }
+ return swift_argument_value(ts_node_named_child(inner, 0));
+}
+
static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) {
/* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the
* client URL joins the server route's canonical placeholder (issue #1006). */
@@ -2315,15 +2378,12 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
}
/* Swift wraps each argument in a value_argument that may lead with its
* label, so `data(from: url)` would otherwise yield the label `from`
- * rather than the value. Step past a leading value_argument_label. */
- if (strcmp(ts_node_type(arg), "value_argument") == 0 &&
- ts_node_named_child_count(arg) > 0) {
- TSNode val = ts_node_named_child(arg, 0);
- if (strcmp(ts_node_type(val), "value_argument_label") == 0 &&
- ts_node_named_child_count(arg) > 1) {
- val = ts_node_named_child(arg, 1);
- }
- arg = val;
+ * rather than the value. */
+ arg = swift_argument_value(arg);
+ /* A Swift URL is usually built rather than written bare, and the
+ * literal then sits inside that constructor. */
+ if (ctx->language == CBM_LANG_SWIFT) {
+ arg = swift_unwrap_url_constructor(ctx, arg);
}
const char *ak = ts_node_type(arg);
@@ -2965,6 +3025,28 @@ static char *resolve_objectscript_instance_call(CBMArena *a, TSNode node, const
* statically-known type here, so the call must not bind by short name alone.
* Note `self.client.send()` is NOT exempt: the receiver is `self.client`, an
* attribute of unknown type, not `self` itself. */
+/* True when a Python attribute-call receiver is an attribute chain ROOTED at
+ * self/cls but is not self/cls itself: `self.compiler.apply_converters()` has
+ * receiver `self.compiler`, an object the class owns. The weak-member guard's
+ * unique-name exemption keys on this shape (see cbm_weak_member_unique_name_
+ * exempt): a bare parameter (`accelerator.backward()`) carries no ownership
+ * evidence and stays suppressed. Direct `self.m()` is already exempt via
+ * python_receiver_is_exempt and is deliberately NOT flagged here. */
+static bool python_receiver_rooted_at_self(CBMExtractCtx *ctx, TSNode receiver) {
+ if (ts_node_is_null(receiver) || strcmp(ts_node_type(receiver), "attribute") != 0) {
+ return false;
+ }
+ TSNode root = receiver;
+ while (!ts_node_is_null(root) && strcmp(ts_node_type(root), "attribute") == 0) {
+ root = ts_node_child_by_field_name(root, TS_FIELD("object"));
+ }
+ if (ts_node_is_null(root) || strcmp(ts_node_type(root), "identifier") != 0) {
+ return false;
+ }
+ char *name = cbm_node_text(ctx->arena, root, ctx->source);
+ return name && (strcmp(name, "self") == 0 || strcmp(name, "cls") == 0);
+}
+
static bool python_receiver_is_exempt(CBMExtractCtx *ctx, TSNode receiver) {
if (ts_node_is_null(receiver)) {
return false;
@@ -3695,6 +3777,7 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "attribute") == 0) {
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
call.is_method = !python_receiver_is_exempt(ctx, obj);
+ call.receiver_is_self_attribute = python_receiver_rooted_at_self(ctx, obj);
} else if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
call.callee_is_locally_bound = python_callee_is_bound_parameter(ctx, state, fn);
}
diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c
index 93da084be..916f1136f 100644
--- a/internal/cbm/extract_defs.c
+++ b/internal/cbm/extract_defs.c
@@ -3477,6 +3477,159 @@ static TSNode find_function_params(TSNode func_node, CBMLanguage lang) {
return params;
}
+/* ── C-family declared return type ──────────────────────────────────
+ * The C-family grammars split a declared return type three ways: the `type`
+ * field (`char`), sibling type_qualifier nodes (`const`), and the
+ * pointer/reference declarators wrapping the function declarator (`*`). Taking
+ * only the `type` field published `const char *get_name(void)` as "char".
+ *
+ * Canonical spelling: leading cv-qualifiers in source order, the base type text
+ * verbatim, then one space and the declarator markers outermost-first with no
+ * space between them — `const char *`, `char **`, `Text &`, `Text *&`. A
+ * qualifier on a pointer level follows its `*` and is separated from the next
+ * marker by a space: `char *const *`. A qualifier written after the base type
+ * (`char const *`) is normalized to the leading position. */
+
+/* Output sink: measures when buf is NULL, writes otherwise. Rendering twice
+ * sizes the arena allocation exactly without a second copy of the logic. */
+typedef struct {
+ char *buf;
+ size_t len;
+} c_rt_out_t;
+
+static void c_rt_put(c_rt_out_t *out, const char *text, size_t n) {
+ if (out->buf) {
+ memcpy(out->buf + out->len, text, n);
+ }
+ out->len += n;
+}
+
+static void c_rt_put_str(c_rt_out_t *out, const char *text) {
+ c_rt_put(out, text, strlen(text));
+}
+
+static void c_rt_put_node(c_rt_out_t *out, TSNode node, const char *source) {
+ uint32_t start = ts_node_start_byte(node);
+ uint32_t end = ts_node_end_byte(node);
+ if (end > start) {
+ c_rt_put(out, source + start, (size_t)(end - start));
+ }
+}
+
+/* type_qualifier also covers keywords that are not part of the type
+ * (`constexpr`, `_Noreturn`, `mutable`, `__extension__`, …). Keep only the ones
+ * that are, so `constexpr int f()` still returns "int". */
+static bool is_c_return_cv_qualifier(TSNode node, const char *source) {
+ static const char *const kept[] = {"const", "volatile", "restrict", "__restrict",
+ "__restrict__", "_Atomic", NULL};
+ if (strcmp(ts_node_type(node), "type_qualifier") != 0) {
+ return false;
+ }
+ uint32_t start = ts_node_start_byte(node);
+ uint32_t end = ts_node_end_byte(node);
+ size_t len = end > start ? (size_t)(end - start) : 0;
+ for (const char *const *k = kept; *k; k++) {
+ if (strlen(*k) == len && memcmp(source + start, *k, len) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool is_c_declarator_lang(CBMLanguage lang) {
+ return lang == CBM_LANG_C || lang == CBM_LANG_CPP || lang == CBM_LANG_CUDA ||
+ lang == CBM_LANG_GLSL || lang == CBM_LANG_HLSL || lang == CBM_LANG_ISPC ||
+ lang == CBM_LANG_SLANG || lang == CBM_LANG_OBJC;
+}
+
+/* Render the canonical return type into `out`; returns how many qualifiers and
+ * markers were added around the base type (0 = the base text alone is already
+ * the whole type). The declarator walk is one strict child chain, so it is
+ * O(depth) with no recursion and needs no depth cap. It stops at the first node
+ * that is neither a pointer nor a reference declarator: for a function returning
+ * a function pointer (`int (*f(void))(int)`) that is the outer
+ * function_declarator, which leaves the base type as it was. */
+static size_t c_rt_render(c_rt_out_t *out, TSNode func_node, TSNode type_node, TSNode declarator,
+ const char *source) {
+ size_t added = 0;
+ uint32_t decl_start = ts_node_start_byte(declarator);
+ uint32_t nc = ts_node_named_child_count(func_node);
+ for (uint32_t i = 0; i < nc; i++) {
+ TSNode ch = ts_node_named_child(func_node, i);
+ if (ts_node_start_byte(ch) >= decl_start) {
+ break;
+ }
+ if (is_c_return_cv_qualifier(ch, source)) {
+ c_rt_put_node(out, ch, source);
+ c_rt_put_str(out, " ");
+ added++;
+ }
+ }
+ c_rt_put_node(out, type_node, source);
+
+ bool need_space = true;
+ TSNode decl = declarator;
+ while (!ts_node_is_null(decl)) {
+ const char *dk = ts_node_type(decl);
+ bool is_ref = strcmp(dk, "reference_declarator") == 0;
+ if (!is_ref && strcmp(dk, "pointer_declarator") != 0) {
+ break;
+ }
+ if (need_space) {
+ c_rt_put_str(out, " ");
+ need_space = false;
+ }
+ /* A reference_declarator opens with its `&` / `&&` token. */
+ TSNode marker = ts_node_child(decl, 0);
+ if (is_ref && !ts_node_is_null(marker) && !ts_node_is_named(marker)) {
+ c_rt_put_node(out, marker, source);
+ } else {
+ c_rt_put_str(out, is_ref ? "&" : "*");
+ }
+ added++;
+ uint32_t dn = ts_node_named_child_count(decl);
+ for (uint32_t i = 0; i < dn; i++) {
+ TSNode q = ts_node_named_child(decl, i);
+ if (is_c_return_cv_qualifier(q, source)) {
+ c_rt_put_node(out, q, source);
+ need_space = true;
+ added++;
+ }
+ }
+ /* tree-sitter-cpp/-cuda give a reference_declarator's inner declarator no
+ * `declarator` field (see find_c_params); it is the one named child. */
+ TSNode inner = ts_node_child_by_field_name(decl, TS_FIELD("declarator"));
+ if (ts_node_is_null(inner) && is_ref && dn > 0) {
+ inner = ts_node_named_child(decl, 0);
+ }
+ decl = inner;
+ }
+ return added;
+}
+
+/* Declared return type of a C-family function/method node whose `type` field is
+ * `type_node`. Any other language, and any type with nothing around its base
+ * type, gets the base type text exactly as before. */
+static char *c_declared_return_type(CBMExtractCtx *ctx, TSNode func_node, TSNode type_node) {
+ CBMArena *a = ctx->arena;
+ TSNode declarator = ts_node_child_by_field_name(func_node, TS_FIELD("declarator"));
+ if (!is_c_declarator_lang(ctx->language) || ts_node_is_null(declarator)) {
+ return cbm_node_text(a, type_node, ctx->source);
+ }
+ c_rt_out_t out = {NULL, 0};
+ if (c_rt_render(&out, func_node, type_node, declarator, ctx->source) == 0) {
+ return cbm_node_text(a, type_node, ctx->source);
+ }
+ out.buf = (char *)cbm_arena_alloc(a, out.len + NULL_TERM);
+ if (!out.buf) {
+ return cbm_node_text(a, type_node, ctx->source);
+ }
+ out.len = 0;
+ (void)c_rt_render(&out, func_node, type_node, declarator, ctx->source);
+ out.buf[out.len] = '\0';
+ return out.buf;
+}
+
// C++: resolve trailing return type (auto f() -> Type) on a declarator node.
// Updates def->return_type and def->return_types if trailing type found.
static void resolve_cpp_trailing_return(CBMArena *a, TSNode func_node, const char *source,
@@ -3712,7 +3865,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
for (const char **f = rt_fields; *f; f++) {
TSNode rt = ts_node_child_by_field_name(func_node, *f, (uint32_t)strlen(*f));
if (!ts_node_is_null(rt)) {
- def.return_type = cbm_node_text(a, rt, ctx->source);
+ def.return_type = c_declared_return_type(ctx, func_node, rt);
def.return_types = extract_return_types(a, rt, ctx->source, ctx->language);
break;
}
@@ -4964,7 +5117,7 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node,
for (const char **f = rt_fields; *f; f++) {
TSNode rt = ts_node_child_by_field_name(child, *f, (uint32_t)strlen(*f));
if (!ts_node_is_null(rt)) {
- def.return_type = cbm_node_text(a, rt, ctx->source);
+ def.return_type = c_declared_return_type(ctx, child, rt);
break;
}
}
diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c
index 544020db3..fcdfc8e09 100644
--- a/internal/cbm/extract_imports.c
+++ b/internal/cbm/extract_imports.c
@@ -133,6 +133,19 @@ static void parse_go_import_spec(CBMExtractCtx *ctx, TSNode spec) {
return;
}
+ /* cgo: `import "C"` names the cgo PSEUDO-package, not a real one — "C" is
+ * reserved by go/build and no package with that import path can exist.
+ * Emitting it as an ordinary import makes the import resolver fall through
+ * to its symbol-name fallback (cbm_pipeline_resolve_import_node Strategy 3,
+ * pass_pkgmap.c), which matches the literal name "C" against every project
+ * node called C and picks the lexicographically smallest: on a real Go repo
+ * all 27 cgo files pointed their IMPORTS edge at the same unrelated `C`
+ * member of a test helper. Drop it — the C side is not a graph package, and
+ * the file's remaining imports are unaffected. */
+ if (strcmp(path, "C") == 0) {
+ return;
+ }
+
TSNode name_node = ts_node_child_by_field_name(spec, TS_FIELD("name"));
const char *local_name =
!ts_node_is_null(name_node) ? cbm_node_text(a, name_node, ctx->source) : path_last(a, path);
diff --git a/internal/cbm/preprocessor.cpp b/internal/cbm/preprocessor.cpp
index 54cb073fe..9be6298f3 100644
--- a/internal/cbm/preprocessor.cpp
+++ b/internal/cbm/preprocessor.cpp
@@ -42,6 +42,283 @@ static bool has_preprocessor_work(const char *source, int source_len) {
return false;
}
+// ── Export-macro candidates (#1989) ─────────────────────────────────────────
+// Build systems define symbol-export macros empty on the compiler command line
+// (UE's UBT: `/D "MODULE_API="`; CMake generate_export_header: `_EXPORT`),
+// so they never appear as #define lines in the source. tree-sitter has no
+// preprocessor state either, and `class MODULE_API Foo` misparses with the
+// macro token as the type name (worse: enums and free functions are lost to
+// ERROR regions entirely). To mirror the real compile line, the preprocessed
+// second pass predefines the conventional export-macro-shaped identifiers found
+// in the file as empty. The shape is deliberately narrow — ALL_CAPS identifier
+// ending in a known export suffix, with a non-trivial prefix — and the list is
+// capped, so ordinary all-caps identifiers cannot be swept in wholesale.
+static const char *kExportMacroSuffixes[] = {"_API", "_EXPORT", "_IMPORT",
+ "_DLLEXPORT", "_DEPRECATED", NULL};
+
+static bool is_export_macro_shape(const char *id, size_t len) {
+ if (id[0] < 'A' || id[0] > 'Z') {
+ return false;
+ }
+ for (size_t i = 0; i < len; i++) {
+ char c = id[i];
+ bool upper = c >= 'A' && c <= 'Z';
+ bool digit = c >= '0' && c <= '9';
+ if (!upper && !digit && c != '_') {
+ return false;
+ }
+ }
+ for (int s = 0; kExportMacroSuffixes[s]; s++) {
+ size_t slen = strlen(kExportMacroSuffixes[s]);
+ // Require at least two prefix characters before the suffix so a bare
+ // "X_API"-style token (single-letter, easily a genuine symbol) stays out.
+ if (len >= slen + 2 && strncmp(id + len - slen, kExportMacroSuffixes[s], slen) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool is_identifier_start(char c) {
+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_';
+}
+
+static bool is_identifier_char(char c) {
+ return is_identifier_start(c) || (c >= '0' && c <= '9');
+}
+
+// C++ raw string literals (R"delim(...)delim"). Returns the index one past
+// the closing quote, or -1 when the form cannot be confidently recognized
+// (unterminated, malformed delimiter) — the caller then STOPS collecting so
+// the mis-modeled literal cannot poison the scan state of the rest of the
+// file (#1989 review).
+static int skip_raw_string(const char *source, int source_len, int quote) {
+ int j = quote + 1;
+ int delim_start = j;
+ while (j < source_len) {
+ char c = source[j];
+ if (c == '(') {
+ break;
+ }
+ // Delimiter chars: the standard d-char set excludes space, parens,
+ // backslash, and the line breaks — a double quote IS a legal d-char
+ // (R"""(...)""" compiles), so it must not end the delimiter (#1989
+ // review round 3).
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ')' || c == '\\' ||
+ j - delim_start >= 16) {
+ return -1;
+ }
+ j++;
+ }
+ if (j >= source_len || source[j] != '(') {
+ return -1;
+ }
+ int delim_len = j - delim_start;
+ j++; // past the opening "("
+ while (j < source_len) {
+ if (source[j] == ')') {
+ int k = j + 1;
+ int m = 0;
+ while (m < delim_len && k + m < source_len &&
+ source[k + m] == source[delim_start + m]) {
+ m++;
+ }
+ if (m == delim_len && k + m < source_len && source[k + m] == '"') {
+ return k + m + 1;
+ }
+ }
+ j++;
+ }
+ return -1; // unterminated raw string: uncertain
+}
+
+// If the identifier just scanned is a raw-string prefix (R, LR, uR, UR, u8R)
+// immediately followed by a quote, return skip_raw_string(...). Returns 0 for
+// an ordinary identifier (including an identifier merely ENDING in R — only a
+// standalone prefix token starts a raw string) and -1 on an unrecognizable
+// raw form.
+static int raw_string_after_prefix(const char *source, int source_len, int start, int end) {
+ static const char *prefixes[] = {"R", "LR", "uR", "UR", "u8R", NULL};
+ size_t idlen = (size_t)(end - start);
+ bool is_prefix = false;
+ for (int p = 0; prefixes[p]; p++) {
+ if (idlen == strlen(prefixes[p]) && strncmp(source + start, prefixes[p], idlen) == 0) {
+ is_prefix = true;
+ break;
+ }
+ }
+ if (!is_prefix || end >= source_len || source[end] != '"') {
+ return 0;
+ }
+ return skip_raw_string(source, source_len, end);
+}
+
+// Advance past comments and string/char literals so only real code tokens
+// consume the candidate budget (#1989 review). Handles line-spliced //
+// comments (a backslash before the newline continues the comment — line
+// splicing happens before comment recognition) and, via the identifier-scan
+// hook in the callers, C++ raw string literals. A digit separator (1'000'000)
+// can still over-skip — that fails safe, toward NOT collecting a candidate.
+static int skip_non_code(const char *source, int source_len, int i) {
+ while (i < source_len) {
+ char c = source[i];
+ if (c == '/' && i + 1 < source_len && source[i + 1] == '/') {
+ i += 2;
+ for (;;) {
+ while (i < source_len && source[i] != '\n') {
+ i++;
+ }
+ if (i >= source_len) {
+ break;
+ }
+ // Line splice: a backslash right before the newline (allowing
+ // \r\n) means the comment continues on the next line.
+ int back = i - 1;
+ if (back >= 0 && source[back] == '\r') {
+ back--;
+ }
+ if (back >= 0 && source[back] == '\\') {
+ i++; // spliced: keep consuming inside the comment
+ continue;
+ }
+ i++; // real end-of-comment newline
+ break;
+ }
+ } else if (c == '/' && i + 1 < source_len && source[i + 1] == '*') {
+ i += 2;
+ while (i + 1 < source_len && !(source[i] == '*' && source[i + 1] == '/')) {
+ i++;
+ }
+ if (i + 1 < source_len) {
+ i += 2; // past the closing "*/"
+ } else {
+ i = source_len; // unterminated comment runs to EOF
+ }
+ } else if (c == '"' || c == '\'') {
+ char quote = c;
+ i++;
+ while (i < source_len) {
+ if (source[i] == '\\') {
+ i += 2;
+ continue;
+ }
+ if (source[i] == quote) {
+ i++;
+ break;
+ }
+ i++;
+ }
+ } else {
+ return i;
+ }
+ }
+ return i;
+}
+
+// Count-only scan used by the early gate (no storage).
+static bool has_export_macro_candidates(const char *source, int source_len) {
+ if (!source || source_len <= 0) {
+ return false;
+ }
+ int i = 0;
+ while (i < source_len) {
+ i = skip_non_code(source, source_len, i);
+ if (i >= source_len) {
+ break;
+ }
+ if (!is_identifier_start(source[i])) {
+ i++;
+ continue;
+ }
+ int start = i;
+ while (i < source_len && is_identifier_char(source[i])) {
+ i++;
+ }
+ // A raw-string prefix consumes everything up to its closing quote;
+ // an unrecognizable raw form poisons the scan state, so fail toward
+ // NOT running the second pass (raw behavior preserved).
+ int raw = raw_string_after_prefix(source, source_len, start, i);
+ if (raw < 0) {
+ return false;
+ }
+ if (raw > 0) {
+ i = raw;
+ continue;
+ }
+ if (is_export_macro_shape(source + start, (size_t)(i - start))) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static int collect_export_macro_candidates(const char *source, int source_len,
+ char (*out)[CBM_EXPORT_MACRO_NAME_MAX], int max_out) {
+ if (!source || source_len <= 0 || !out || max_out <= 0) {
+ return 0;
+ }
+ int stored = 0;
+ int i = 0;
+ while (i < source_len) {
+ i = skip_non_code(source, source_len, i);
+ if (i >= source_len) {
+ break;
+ }
+ if (!is_identifier_start(source[i])) {
+ i++;
+ continue;
+ }
+ int start = i;
+ while (i < source_len && is_identifier_char(source[i])) {
+ i++;
+ }
+ // Raw string (see has_export_macro_candidates): skip its body, or stop
+ // collecting entirely when the form cannot be confidently recognized —
+ // continuing would mis-tokenize the rest of the file and could both
+ // mint phantom candidates and mask real ones (#1989 review).
+ int raw = raw_string_after_prefix(source, source_len, start, i);
+ if (raw < 0) {
+ break;
+ }
+ if (raw > 0) {
+ i = raw;
+ continue;
+ }
+ size_t len = (size_t)(i - start);
+ /* Length gate FIRST: the dedup below reads out[k][0..len] against rows
+ * of CBM_EXPORT_MACRO_NAME_MAX bytes — an over-long candidate must be
+ * rejected before any compare touches them. */
+ if (len >= CBM_EXPORT_MACRO_NAME_MAX) {
+ continue;
+ }
+ if (!is_export_macro_shape(source + start, len)) {
+ continue;
+ }
+ bool dup = false;
+ for (int k = 0; k < stored; k++) {
+ if (strncmp(out[k], source + start, len) == 0 && out[k][len] == '\0') {
+ dup = true;
+ break;
+ }
+ }
+ if (dup) {
+ continue;
+ }
+ if (stored >= max_out) {
+ break; // bounded: stop collecting once the cap is reached
+ }
+ memcpy(out[stored], source + start, len);
+ out[stored][len] = '\0';
+ stored++;
+ }
+ return stored;
+}
+
+int cbm_export_macro_candidates(const char *source, int source_len,
+ char (*out)[CBM_EXPORT_MACRO_NAME_MAX], int max_out) {
+ return collect_export_macro_candidates(source, source_len, out, max_out);
+}
+
static int count_expanded_lines(const std::string &text) {
int count = 1;
for (char c : text) {
@@ -146,7 +423,11 @@ static bool build_line_map(const std::string &expanded, const std::string &main_
CBMPreprocessedSource *cbm_preprocess_with_map(const char *source, int source_len,
const char *filename, const char **extra_defines,
const char **include_paths, int cpp_mode) {
- if (!has_preprocessor_work(source, source_len)) {
+ // Run the second pass when there are directives to evaluate OR when the file
+ // carries export-macro-shaped identifiers to predefine empty (#1989) — a UE
+ // plugin header with only `#pragma once` + `#include` lines still needs it.
+ if (!has_preprocessor_work(source, source_len) &&
+ !has_export_macro_candidates(source, source_len)) {
return NULL; // NULL = no expansion needed, use original
}
@@ -156,6 +437,28 @@ CBMPreprocessedSource *cbm_preprocess_with_map(const char *source, int source_le
for (int i = 0; extra_defines[i]; i++)
dui.defines.push_back(extra_defines[i]);
}
+ // Predefine collected export-macro candidates as empty, mirroring the
+ // real compile command line (`/D "MODULE_API="`). Names already provided
+ // by the caller win — never override an explicit define.
+ char export_cands[CBM_EXPORT_MACRO_MAX][CBM_EXPORT_MACRO_NAME_MAX];
+ int export_cand_count =
+ collect_export_macro_candidates(source, source_len, export_cands, CBM_EXPORT_MACRO_MAX);
+ for (int i = 0; i < export_cand_count; i++) {
+ bool provided = false;
+ for (std::list::const_iterator it = dui.defines.begin();
+ it != dui.defines.end(); ++it) {
+ const std::string &def = *it;
+ size_t eq = def.find('=');
+ std::string name = (eq == std::string::npos) ? def : def.substr(0, eq);
+ if (name == export_cands[i]) {
+ provided = true;
+ break;
+ }
+ }
+ if (!provided) {
+ dui.defines.push_back(std::string(export_cands[i]) + "=");
+ }
+ }
if (include_paths) {
for (int i = 0; include_paths[i]; i++)
dui.includePaths.push_back(include_paths[i]);
diff --git a/internal/cbm/preprocessor.h b/internal/cbm/preprocessor.h
index 72828c012..1b108b335 100644
--- a/internal/cbm/preprocessor.h
+++ b/internal/cbm/preprocessor.h
@@ -4,6 +4,15 @@
#include
#include
+/* #1989: build-system export macros (UE "_API", CMake generate_export_header
+ * "_EXPORT", ...) are defined empty on the real compile command line but are
+ * invisible to tree-sitter, so `class MOD_API Foo` misparses with the macro token
+ * as the type name. The preprocessed second pass predefines the conventional
+ * export-macro-shaped identifiers found in a source file as empty, mirroring the
+ * real build line. The candidate list is bounded. */
+#define CBM_EXPORT_MACRO_MAX 32
+#define CBM_EXPORT_MACRO_NAME_MAX 96
+
#ifdef __cplusplus
extern "C" {
#endif
@@ -15,6 +24,12 @@ typedef struct {
int expanded_line_count;
} CBMPreprocessedSource;
+// Collect conventional export-macro-shaped identifiers (ALL_CAPS ending in
+// _API/_EXPORT/_IMPORT/_DLLEXPORT/_DEPRECATED) from the source, up to max_out
+// unique names. Returns the number of names stored in out.
+int cbm_export_macro_candidates(const char *source, int source_len,
+ char (*out)[CBM_EXPORT_MACRO_NAME_MAX], int max_out);
+
// Preprocess C/C++ source: expand macros, evaluate #ifdef, resolve #include.
// Returns malloc-allocated expanded source, or NULL if no expansion needed/on failure.
// extra_defines: NULL-terminated array of "NAME=VALUE" strings (can be NULL).
diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c
index e3740f5aa..c2425e09e 100644
--- a/internal/cbm/service_patterns.c
+++ b/internal/cbm/service_patterns.c
@@ -140,6 +140,20 @@ static const lib_pattern_t http_libraries[] = {
{"socket.http", CBM_SVC_HTTP, NULL},
{"resty.http", CBM_SVC_HTTP, NULL},
+ /* Glued-name libraries. match_qn needs an identifier boundary around an
+ * id, so a library whose own name glues a lowercase prefix/suffix onto
+ * another id ("grequests", "curlpp") is listed explicitly: no boundary
+ * rule can tell "grequests" from "myrequests". */
+ {"grequests", CBM_SVC_HTTP, NULL}, /* Python: gevent + requests */
+ {"txrequests", CBM_SVC_HTTP, NULL}, /* Python: Twisted + requests */
+ {"redaxios", CBM_SVC_HTTP, NULL}, /* JS: axios-compatible fetch client */
+ {"gaxios", CBM_SVC_HTTP, NULL}, /* JS: Google's axios-style client */
+ {"libcurl", CBM_SVC_HTTP, NULL}, /* C, node-libcurl */
+ {"curlpp", CBM_SVC_HTTP, NULL}, /* C++ libcurl wrapper */
+ {"curlcpp", CBM_SVC_HTTP, NULL}, /* C++ libcurl wrapper */
+ {"hyperlocal", CBM_SVC_HTTP, NULL}, /* Rust: hyper over unix sockets */
+ {"guzzlehttp", CBM_SVC_HTTP, NULL}, /* PHP: Guzzle's composer vendor */
+
{NULL, CBM_SVC_NONE, NULL},
};
@@ -263,6 +277,33 @@ static const lib_pattern_t async_libraries[] = {
{"dapr.clients.grpc", CBM_SVC_ASYNC, "dapr"},
{"DaprClient", CBM_SVC_ASYNC, "dapr"},
+ /* Glued-name libraries (see the note in http_libraries). */
+ {"aiokafka", CBM_SVC_ASYNC, "kafka"},
+ {"pykafka", CBM_SVC_ASYNC, "kafka"},
+ {"librdkafka", CBM_SVC_ASYNC, "kafka"},
+ {"rdkafkacpp", CBM_SVC_ASYNC, "kafka"},
+ {"rskafka", CBM_SVC_ASYNC, "kafka"},
+ {"aioamqp", CBM_SVC_ASYNC, "rabbitmq"},
+ {"amqpstorm", CBM_SVC_ASYNC, "rabbitmq"},
+ {"pamqp", CBM_SVC_ASYNC, "rabbitmq"},
+ {"pyamqp", CBM_SVC_ASYNC, "rabbitmq"},
+ {"amqprs", CBM_SVC_ASYNC, "rabbitmq"},
+ {"amqpcpp", CBM_SVC_ASYNC, "rabbitmq"},
+ {"pynats", CBM_SVC_ASYNC, "nats"},
+ {"jnats", CBM_SVC_ASYNC, "nats"},
+ {"aiomqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"amqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"hbmqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"umqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"mqttools", CBM_SVC_ASYNC, "mqtt"},
+ {"emqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"rumqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"gomqtt", CBM_SVC_ASYNC, "mqtt"},
+ {"libmosquitto", CBM_SVC_ASYNC, "mqtt"},
+ {"mosquittopp", CBM_SVC_ASYNC, "mqtt"},
+ {"pubsublite", CBM_SVC_ASYNC, "pubsub"},
+ {"gocelery", CBM_SVC_ASYNC, "celery"},
+
{NULL, CBM_SVC_NONE, NULL},
};
@@ -308,6 +349,13 @@ static const lib_pattern_t config_libraries[] = {
{"Application.get_env", CBM_SVC_CONFIG, NULL},
{"Application.fetch_env", CBM_SVC_CONFIG, NULL},
+ /* Glued-name accessors (see the note in http_libraries). */
+ {"wgetenv", CBM_SVC_CONFIG, NULL}, /* Windows CRT: _wgetenv, _wgetenv_s */
+ {"getenvb", CBM_SVC_CONFIG, NULL}, /* Python: os.getenvb */
+ {"qgetenv", CBM_SVC_CONFIG, NULL}, /* Qt */
+ {"dotenvx", CBM_SVC_CONFIG, NULL}, /* JS: @dotenvx/dotenvx */
+ {"phpdotenv", CBM_SVC_CONFIG, NULL}, /* PHP: vlucas/phpdotenv */
+
{NULL, CBM_SVC_NONE, NULL},
};
@@ -377,6 +425,11 @@ static const lib_pattern_t route_reg_libraries[] = {
{"akka.http.scaladsl.server", CBM_SVC_ROUTE_REG, NULL},
{"play.api.routing", CBM_SVC_ROUTE_REG, NULL},
+ /* Glued-name frameworks (see the note in http_libraries). */
+ {"apiflask", CBM_SVC_ROUTE_REG, NULL}, /* Python: Flask-based */
+ {"honox", CBM_SVC_ROUTE_REG, NULL}, /* JS: Hono meta-framework */
+ {"fasthttprouter", CBM_SVC_ROUTE_REG, NULL}, /* Go: httprouter for fasthttp */
+
{NULL, CBM_SVC_NONE, NULL},
};
@@ -448,6 +501,11 @@ static const lib_pattern_t graphql_libraries[] = {
{"async-graphql", CBM_SVC_GRAPHQL, NULL},
{"juniper", CBM_SVC_GRAPHQL, NULL},
+ /* Glued-name libraries (see the note in http_libraries). */
+ {"gqlparser", CBM_SVC_GRAPHQL, NULL}, /* Go: vektah/gqlparser */
+ {"gqlgenc", CBM_SVC_GRAPHQL, NULL}, /* Go: gqlgen client generator */
+ {"aiogqlc", CBM_SVC_GRAPHQL, NULL}, /* Python: asyncio GraphQL client */
+
{NULL, CBM_SVC_NONE, NULL},
};
@@ -534,17 +592,67 @@ static const method_suffix_t method_suffixes[] = {
/* ── Matching implementation ───────────────────────────────────── */
-/* Check if any library identifier appears as a substring in the QN.
- * Case-sensitive: "requests" matches "project.venv.requests.api.get"
- * but not "Requests". Library names are specific enough to avoid
- * false positives even with substring matching. */
+static bool qn_is_lower(char ch) {
+ return ch >= 'a' && ch <= 'z';
+}
+
+static bool is_digit_char(char ch) {
+ return ch >= '0' && ch <= '9';
+}
+
+static bool qn_is_alnum(char ch) {
+ return qn_is_lower(ch) || is_digit_char(ch) || (ch >= 'A' && ch <= 'Z');
+}
+
+/* True when the occurrence of `id` at `hit` sits on identifier boundaries.
+ *
+ * A raw substring match let short ids fire inside unrelated words: "gin."
+ * in "plugin.", "dio" in "studio", "surf" in "surface", "express" in
+ * "expression". Every character that is not an ASCII letter or digit is a
+ * separator ('.', '/', '\\', ':', '_', '-', '$', '@', ...).
+ *
+ * BEFORE: start of string, a separator, an id that itself starts with a
+ * separator ("@trpc/server"), or an id that starts with an
+ * uppercase letter — a capital opens a new CamelCase word whatever
+ * precedes it ("AsyncHttpClient", "IHttpClientFactory",
+ * "NSURLSession"). Rejected: a lowercase/digit-initial id glued to
+ * a letter or digit ("plugin." / "studio" / "myrequests").
+ * AFTER: end of string, a separator, an id that itself ends in a
+ * separator ("gin."), an uppercase letter (CamelCase glue:
+ * "GuzzleHttp", "FeignClient", "KafkaProducer", "kafkaProducer")
+ * or a digit (version suffix: "urllib2", "amqp091-go").
+ * Rejected: a lowercase letter continuing the word ("surface",
+ * "curly", "expression"). */
+static bool qn_hit_on_boundary(const char *qn, const char *hit, const char *id, size_t id_len) {
+ char first = id[0];
+ char last = id[id_len - 1];
+ if (hit > qn && qn_is_alnum(hit[-1]) && (qn_is_lower(first) || is_digit_char(first))) {
+ return false;
+ }
+ if (qn_is_alnum(last) && qn_is_lower(hit[id_len])) {
+ return false;
+ }
+ return true;
+}
+
+/* Check if any library identifier appears in the QN on identifier
+ * boundaries (see qn_hit_on_boundary). Case-sensitive: "requests" matches
+ * "project.venv.requests.api.get" and "svc.requests_get" but neither
+ * "Requests" nor "myrequests". */
static const lib_pattern_t *match_qn(const char *qn, const lib_pattern_t *patterns) {
if (!qn || !qn[0]) {
return NULL;
}
for (int i = 0; patterns[i].library_id != NULL; i++) {
- if (strstr(qn, patterns[i].library_id) != NULL) {
- return &patterns[i];
+ const char *id = patterns[i].library_id;
+ size_t id_len = strlen(id);
+ if (id_len == 0) {
+ continue;
+ }
+ for (const char *hit = strstr(qn, id); hit != NULL; hit = strstr(hit + 1, id)) {
+ if (qn_hit_on_boundary(qn, hit, id, id_len)) {
+ return &patterns[i];
+ }
}
}
return NULL;
@@ -574,10 +682,6 @@ static bool contains_segment(const char *path, const char *segment) {
return false;
}
-static bool is_digit_char(char ch) {
- return ch >= '0' && ch <= '9';
-}
-
static bool has_http_route_marker(const char *path) {
if (starts_with_segment(path, "api") || starts_with_segment(path, "apis") ||
starts_with_segment(path, "graphql") || starts_with_segment(path, "health") ||
@@ -714,6 +818,25 @@ static const char *strip_string_delimiters(const char *literal, char *buf, size_
return buf;
}
+/* A comment opens with the slash a route opens with, and an argument list
+ * that starts with one handed three Java block comments to the Route pass as
+ * URLs (elasticsearch, 2026-09-16). The wildcard route (slash-star alone) is a
+ * path, so the block-comment shape needs its closing star-slash; a route
+ * literal never holds a line break. */
+bool cbm_service_pattern_is_comment_text(const char *text) {
+ if (!text || text[0] != '/') {
+ return false;
+ }
+ if (strchr(text, '\n') != NULL || strchr(text, '\r') != NULL) {
+ return true;
+ }
+ size_t n = strlen(text);
+ if (text[1] == '*' && n >= 4 && text[n - 2] == '*' && text[n - 1] == '/') {
+ return true;
+ }
+ return text[1] == '/' && (text[2] == ' ' || text[2] == '\t');
+}
+
bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name) {
char path_buf[1024];
const char *path = strip_string_delimiters(literal, path_buf, sizeof(path_buf));
@@ -729,6 +852,9 @@ bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *
if (path[0] != '/') {
return false;
}
+ if (cbm_service_pattern_is_comment_text(path)) {
+ return false;
+ }
if (callee_is_delimiter_or_filesystem_builder(callee_name)) {
return false;
}
diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h
index 4642a08de..d9a93c251 100644
--- a/internal/cbm/service_patterns.h
+++ b/internal/cbm/service_patterns.h
@@ -48,6 +48,12 @@ bool cbm_service_pattern_is_global_fetch(const char *callee_name);
* Rejects filesystem paths and non-HTTP string consumers. */
bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name);
+/* True when text that reached a path slot is a source comment rather than a
+ * path: a block comment (slash-star … star-slash), a line comment (slash-slash
+ * followed by a space), or any text with a line break. The wildcard route
+ * (slash-star alone) is a path. */
+bool cbm_service_pattern_is_comment_text(const char *text);
+
/* Per-worker TLS cache for cbm_service_pattern_match results. The
* pattern matcher runs once per resolved CALL edge in emit_service_
* edge — that's 6 pattern lists × ~30 patterns × strstr per call ≈
diff --git a/internal/cbm/sqlite_writer.c b/internal/cbm/sqlite_writer.c
index 55ab937d4..da8aba06c 100644
--- a/internal/cbm/sqlite_writer.c
+++ b/internal/cbm/sqlite_writer.c
@@ -28,6 +28,7 @@
#include
#include
#include
+#include
#ifdef _WIN32
#include
@@ -1777,6 +1778,10 @@ static int sync_writer_output(FILE *fp) {
}
static int discard_writer_output(write_db_ctx_t *w, int rc) {
+ /* Cleanup runs after the failure that brought us here, and a library
+ * call may set errno even when it succeeds. Carry the reason across it
+ * so the caller can report WHY the publish failed. */
+ int failure_errno = errno;
if (w->fp) {
(void)fclose(w->fp);
w->fp = NULL;
@@ -1784,6 +1789,7 @@ static int discard_writer_output(write_db_ctx_t *w, int rc) {
if (w->temp_path[0]) {
(void)cbm_unlink(w->temp_path);
}
+ errno = failure_errno;
return rc;
}
@@ -1792,10 +1798,12 @@ static int publish_writer_output(write_db_ctx_t *w) {
return discard_writer_output(w, ERR_WRITE_FAILED);
}
if (fclose(w->fp) != 0) {
+ int failure_errno = errno;
w->fp = NULL;
if (w->temp_path[0]) {
(void)cbm_unlink(w->temp_path);
}
+ errno = failure_errno;
return ERR_WRITE_FAILED;
}
w->fp = NULL;
@@ -1803,7 +1811,12 @@ static int publish_writer_output(write_db_ctx_t *w) {
return 0;
}
if (cbm_rename_replace(w->temp_path, w->final_path) != 0) {
+ /* cbm_rename_replace translated the platform error into errno so the
+ * caller can say what denied the publish (#1620). Preserve it across
+ * the cleanup unlink. */
+ int rename_errno = errno;
(void)cbm_unlink(w->temp_path);
+ errno = rename_errno;
return ERR_WRITE_FAILED;
}
/* Sidecars are removed only after the replacement succeeds. On POSIX,
@@ -2303,13 +2316,22 @@ cbm_db_writer_t *cbm_writer_open(const char *path) {
int n = snprintf(w->wc.final_path, sizeof(w->wc.final_path), "%s", path);
if (n < 0 || (size_t)n >= sizeof(w->wc.final_path) ||
make_writer_temp_path(path, w, w->wc.temp_path, sizeof(w->wc.temp_path)) != 0) {
+ /* Both conditions are truncation and neither sets errno; name the
+ * reason rather than let the caller report a stale one. */
free(w);
+ errno = ENAMETOOLONG;
return NULL;
}
FILE *fp = cbm_fopen(w->wc.temp_path, "wb");
if (!fp) {
+ /* The cleanup below unlinks a file that was never created, so it
+ * fails and leaves ENOENT behind — which reads as a missing path
+ * when the real answer is that the directory refused the create.
+ * That is the #1620 case, so carry the open's reason across it. */
+ int open_errno = errno;
(void)cbm_unlink(w->wc.temp_path);
free(w);
+ errno = open_errno;
return NULL;
}
w->wc.fp = fp;
@@ -2377,6 +2399,10 @@ int cbm_writer_finalize(cbm_db_writer_t *w, const char *project, const char *roo
write_db_ctx_t wc = w->wc; /* value copy survives free(w) */
free(w);
if (err != 0) {
+ /* A sticky append failure: errno belongs to whatever call failed
+ * many calls ago, not to the publish. Clear it so the caller does
+ * not attach a reason this path does not have. */
+ errno = 0;
return discard_writer_output(&wc, err);
}
return write_db_after_nodes(&wc, nodes_root);
diff --git a/pkg/npm/README.md b/pkg/npm/README.md
index 614b81b1c..e1e5abe3e 100644
--- a/pkg/npm/README.md
+++ b/pkg/npm/README.md
@@ -7,7 +7,7 @@
**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. This npm wrapper downloads, verifies, and caches the selected native runtime set: the executable, its authenticated integration asset, and—when requested—the content-addressed UI pack.
-High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across 162 languages — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. No hosted service or API key; this package requires Node.js to manage and launch the cached native runtime set. Plug and play across 45 automatic/conditional client surfaces.
+High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across 162 languages — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 17 MCP tools. No hosted service or API key; this package requires Node.js to manage and launch the cached native runtime set. Plug and play across 45 automatic/conditional client surfaces.
## Installation
@@ -38,7 +38,7 @@ Restart your agent. Say **"Index this project"** — done.
- **Lifecycle hooks stay conservative** — Kimi uses `UserPromptSubmit`; on macOS/Linux, GitLab Duo gets a fail-open user `SessionStart`, while Devin gets `UserPromptSubmit`, `PostCompaction`, and a deduplicated `SessionStart` when Claude does not already provide it. Qoder, GitLab Duo, Devin, and Factory hooks are withheld on Windows without a documented shell/executor contract. Cline's auto-activating file hooks are withheld because their context output is not reliably consumed, CodeBuddy beta hooks are not auto-installed, and Cursor context hooks remain withheld.
- **Subagent access is explicit** — Claude, Gemini, Kiro, Qwen, CodeBuddy, KiloCode, Mistral Vibe, Grok Build, Qoder, Junie, and Factory get documented graph profiles with the narrowest tool/server filters their schemas support. KiloCode and Vibe enumerate read-only query tools rather than using server wildcards. Cursor, Rovo, Pochi, and Cline use explicit parent handoff where child MCP is unavailable or unsafe; IBM Bob receives no invented hook or agent.
- **Manual, UI, cloud, or repository-managed (not counted)** — Qodo, Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI, Plandex, and SWE-agent. Warp is counted above for its detected skill installation; its MCP connection remains manual.
-- **15 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
+- **17 MCP tools** — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
## Supported Platforms
diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit
index fa42d92ae..7b5e1a4ff 100755
--- a/scripts/hooks/pre-commit
+++ b/scripts/hooks/pre-commit
@@ -17,4 +17,13 @@ echo "pre-commit: security audit (source-level)..."
scripts/security-audit.sh
echo "pre-commit: building and running tests..."
-make -j$(sysctl -n hw.ncpu 2>/dev/null || nproc) -f Makefile.cbm test
+(
+ # Git exports repository-local variables to hooks. The test suite creates
+ # independent repositories and linked worktrees, so keep the commit's
+ # index and object paths out of those fixtures.
+ while IFS= read -r variable; do
+ unset "$variable"
+ done < <(git rev-parse --local-env-vars)
+
+ make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" -f Makefile.cbm test
+)
diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt
index 8bc450f7f..ee54b498b 100644
--- a/scripts/memory-core-baseline.txt
+++ b/scripts/memory-core-baseline.txt
@@ -52,7 +52,7 @@ src/git/git_context.c 18
src/main.c 32
src/mcp/compact_out.c 39
src/mcp/index_supervisor.c 8
-src/mcp/mcp.c 814
+src/mcp/mcp.c 784
src/pipeline/artifact.c 38
src/pipeline/fqn.c 23
src/pipeline/lsp_resolve.h 4
diff --git a/src/cli/cli.c b/src/cli/cli.c
index 8645fbe56..b59a894a6 100644
--- a/src/cli/cli.c
+++ b/src/cli/cli.c
@@ -13752,6 +13752,32 @@ static void cli_add_typed(yyjson_mut_doc *out, yyjson_mut_val *obj, const char *
arr = yyjson_mut_arr(out);
yyjson_mut_obj_add(obj, yyjson_mut_strcpy(out, key), arr);
}
+ /* The help prints `--paths `, and a caller who writes the
+ * array literally — `--paths '["lib","t"]'` — used to get ONE element
+ * holding that literal text (2026-09-16 probe: check_index_coverage
+ * reported the fake path `["lib","t"]`). A value that parses as a JSON
+ * array contributes its elements; anything else is one element. */
+ if (have_value && value && value[0] == '[') {
+ yyjson_doc *lit = yyjson_read(value, strlen(value), 0);
+ yyjson_val *lit_root = lit ? yyjson_doc_get_root(lit) : NULL;
+ if (lit_root && yyjson_is_arr(lit_root)) {
+ size_t idx;
+ size_t max;
+ yyjson_val *elem;
+ yyjson_arr_foreach(lit_root, idx, max, elem) {
+ if (yyjson_is_str(elem)) {
+ yyjson_mut_arr_add_strcpy(out, arr, yyjson_get_str(elem));
+ } else {
+ yyjson_mut_arr_add_val(arr, yyjson_val_mut_copy(out, elem));
+ }
+ }
+ yyjson_doc_free(lit);
+ return;
+ }
+ if (lit) {
+ yyjson_doc_free(lit);
+ }
+ }
yyjson_mut_arr_add_strcpy(out, arr, have_value ? value : "");
return;
}
diff --git a/src/cli/cli.h b/src/cli/cli.h
index cf7f6fa44..5522edef0 100644
--- a/src/cli/cli.h
+++ b/src/cli/cli.h
@@ -27,6 +27,18 @@ const char *cbm_cli_get_version(void);
/* ── CLI tool arguments (flags / --args-file / --help) ────────── */
+/* Top-level `cli --help` text printed by run_cli() in src/main.c.
+ * Documents tool-level --format without adding a session-wide flag (#2102). */
+#define CBM_CLI_USAGE \
+ "Usage: codebase-memory-mcp cli [--quiet] [--progress] [--verbose] [--json] " \
+ "[json_args]\n" \
+ " --quiet Show errors only; cannot combine with --progress or outer --verbose\n" \
+ " --progress Show lifecycle progress even when stderr is redirected\n" \
+ " --verbose Include informational logs (preserves CBM_LOG_LEVEL=debug)\n" \
+ " --json Print the raw MCP result envelope\n" \
+ " Tools that accept format support --format tree|json (default: tree).\n" \
+ " --format json prints payload JSON; outer --json prints the full MCP envelope.\n"
+
/* Convert `--flag value` / `--flag=value` / bare-boolean `--flag` arguments for
* a tool into a JSON arguments object string, using the tool's input_schema to
* type values (string/integer/boolean) and to collect repeated flags into
diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c
index dbd913426..38e73cfe0 100644
--- a/src/cli/client_adapter.c
+++ b/src/cli/client_adapter.c
@@ -7,6 +7,7 @@
#include "foundation/constants.h"
#include "mcp/mcp.h"
+#include "yyjson/yyjson.h"
#include
#include
@@ -138,6 +139,167 @@ static void emit_header(adapter_sb_t *sb, const char *client) {
"// are overwritten; edit outside it, or remove the markers to take ownership.\n");
}
+static void append_typebox_type(adapter_sb_t *sb, yyjson_val *val, const char *description,
+ bool include_description) {
+ yyjson_val *type_val = yyjson_obj_get(val, "type");
+ yyjson_val *enum_val = yyjson_obj_get(val, "enum");
+ yyjson_val *items_val = yyjson_obj_get(val, "items");
+ const char *type_str = yyjson_get_str(type_val);
+
+ if (type_str && strcmp(type_str, "string") == 0 && enum_val && yyjson_is_arr(enum_val) &&
+ yyjson_arr_size(enum_val) > 0) {
+ sb_append(sb, "Type.Union([");
+ size_t enum_idx = 0;
+ size_t enum_max = yyjson_arr_size(enum_val);
+ bool first_enum = true;
+ for (enum_idx = 0; enum_idx < enum_max; enum_idx++) {
+ yyjson_val *enum_item = yyjson_arr_get(enum_val, enum_idx);
+ const char *enum_str = yyjson_get_str(enum_item);
+ if (!enum_str) {
+ continue;
+ }
+ if (!first_enum) {
+ sb_append(sb, ", ");
+ }
+ first_enum = false;
+ sb_append(sb, "Type.Literal(");
+ sb_append_js_string(sb, enum_str);
+ sb_append(sb, ")");
+ }
+ if (first_enum) {
+ sb_append(sb, "Type.String()");
+ } else {
+ sb_append(sb, "]");
+ if (include_description && description) {
+ sb_append(sb, ", { description: ");
+ sb_append_js_string(sb, description);
+ sb_append(sb, " }");
+ }
+ sb_append(sb, ")");
+ return;
+ }
+ sb_append(sb, ")");
+ return;
+ }
+
+ const char *typebox_name = "Any";
+ if (type_str && strcmp(type_str, "string") == 0) {
+ typebox_name = "String";
+ } else if (type_str && strcmp(type_str, "integer") == 0) {
+ typebox_name = "Integer";
+ } else if (type_str && strcmp(type_str, "number") == 0) {
+ typebox_name = "Number";
+ } else if (type_str && strcmp(type_str, "boolean") == 0) {
+ typebox_name = "Boolean";
+ }
+
+ if (type_str && strcmp(type_str, "array") == 0) {
+ sb_append(sb, "Type.Array(");
+ if (items_val) {
+ append_typebox_type(sb, items_val, NULL, false);
+ } else {
+ sb_append(sb, "Type.Any()");
+ }
+ if (include_description && description) {
+ sb_append(sb, ", { description: ");
+ sb_append_js_string(sb, description);
+ sb_append(sb, " }");
+ }
+ sb_append(sb, ")");
+ return;
+ }
+
+ sb_append(sb, "Type.");
+ sb_append(sb, typebox_name);
+ sb_append(sb, "(");
+ if (include_description && description) {
+ sb_append(sb, "{ description: ");
+ sb_append_js_string(sb, description);
+ sb_append(sb, " }");
+ }
+ sb_append(sb, ")");
+}
+
+static bool typebox_property_is_required(yyjson_val *required, const char *prop_name) {
+ if (!required || !yyjson_is_arr(required)) {
+ return false;
+ }
+ size_t idx = 0;
+ size_t max = yyjson_arr_size(required);
+ for (idx = 0; idx < max; idx++) {
+ const char *required_name = yyjson_get_str(yyjson_arr_get(required, idx));
+ if (required_name && strcmp(required_name, prop_name) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static void convert_json_schema_to_typebox(adapter_sb_t *sb, const char *schema_str) {
+ if (!schema_str) {
+ sb_append(sb, "Type.Object({})");
+ return;
+ }
+
+ /* Parse the JSON schema to extract properties and convert to TypeBox */
+ yyjson_doc *doc = yyjson_read(schema_str, strlen(schema_str), 0);
+ if (!doc) {
+ sb_append(sb, "Type.Object({})");
+ return;
+ }
+
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ if (!root) {
+ sb_append(sb, "Type.Object({})");
+ yyjson_doc_free(doc);
+ return;
+ }
+
+ yyjson_val *properties = yyjson_obj_get(root, "properties");
+ yyjson_val *required = yyjson_obj_get(root, "required");
+ if (!properties) {
+ sb_append(sb, "Type.Object({})");
+ yyjson_doc_free(doc);
+ return;
+ }
+
+ sb_append(sb, "Type.Object({\n");
+
+ bool first_prop = true;
+ size_t idx = 0, max = 0;
+ yyjson_val *key = NULL, *val = NULL;
+
+ yyjson_obj_foreach(properties, idx, max, key, val) {
+ const char *prop_name = yyjson_get_str(key);
+ if (!prop_name) {
+ continue;
+ }
+
+ if (!first_prop) {
+ sb_append(sb, ",\n");
+ }
+ first_prop = false;
+
+ sb_append(sb, " ");
+ sb_append(sb, prop_name);
+ sb_append(sb, ": ");
+
+ yyjson_val *description_val = yyjson_obj_get(val, "description");
+ const char *description = yyjson_get_str(description_val);
+ bool is_required = typebox_property_is_required(required, prop_name);
+ if (!is_required) {
+ sb_append(sb, "Type.Optional(");
+ }
+ append_typebox_type(sb, val, description, true);
+ if (!is_required) {
+ sb_append(sb, ")");
+ }
+ }
+
+ sb_append(sb, "\n })");
+ yyjson_doc_free(doc);
+}
+
char *cbm_client_adapter_pi(const char *binary_path) {
if (!binary_path || !binary_path[0]) {
return NULL;
@@ -157,7 +319,8 @@ char *cbm_client_adapter_pi(const char *binary_path) {
* (pods CLI) or an old AgentTool arity cannot be mistaken for this file. */
sb_append(&sb, "// Target: @earendil-works/pi-coding-agent >= 0.74.0 (verified 0.84.2)\n"
"// ToolDefinition.execute(toolCallId, params, signal, onUpdate, ctx)\n");
- sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
+ sb_append(&sb, "import { spawn } from 'node:child_process';\n");
+ sb_append(&sb, "import { Type } from 'typebox';\n\n");
sb_append(&sb, "const BIN = '");
sb_append(&sb, bin);
sb_append(&sb, "';\n\n");
@@ -224,10 +387,8 @@ char *cbm_client_adapter_pi(const char *binary_path) {
sb_append(&sb, ",\n description: ");
sb_append_js_string(&sb, description ? description : "");
sb_append(&sb, ",\n parameters: ");
- /* input_schema is compact JSON, which is a valid JavaScript object
- * literal; embedding it directly keeps the generated module free of a
- * JSON.parse indirection and of any escaping drift. */
- sb_append(&sb, schema ? schema : "{}");
+ /* Convert JSON schema to TypeBox format */
+ convert_json_schema_to_typebox(&sb, schema);
/* 0.84.2 calls execute(toolCallId, params, signal, onUpdate, ctx).
* The previous (args, ctx) shape bound the call id as the MCP args. */
sb_append(&sb, ",\n execute: async (toolCallId, params, signal, _onUpdate, ctx) => {\n");
diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c
index 73592a4b8..234f49087 100644
--- a/src/cypher/cypher.c
+++ b/src/cypher/cypher.c
@@ -5014,8 +5014,107 @@ static void execute_return_clause(cbm_query_t *q, cbm_return_clause_t *ret, bind
limit_is_engine_budget);
}
+/* ── Planner: seed a single-hop pattern from its selective end ──────────────
+ *
+ * The executor scans pattern node 0 and expands from there. Written as
+ * `MATCH (a)-[:CALLS]->(b) WHERE b.name = '_printk'`, that scans every node
+ * in the store before the filter on `b` can discard any — on an 8.5 M node
+ * kernel graph the query hit the execution-time limit while the anchored
+ * spelling `MATCH (b:Function {name:'_printk'})<-[:CALLS]-(a)` answered in
+ * seconds (2026-09-16 probe). A reader should not have to know which end to
+ * write first: when the far node carries an equality on name/qualified_name,
+ * an inline property, or a label and the near node carries less, the pattern
+ * is walked from the far end with the relationship direction inverted. The
+ * result set is identical; only the enumeration order changes. Restricted to
+ * one single-relationship pattern with an explicit RETURN, and never to an
+ * OPTIONAL MATCH, whose anchor is part of its meaning. */
+static int cypher_cond_selectivity(const cbm_condition_t *c, const char *var) {
+ if (!c || !c->variable || !var || strcmp(c->variable, var) != 0 || c->negated) {
+ return 0;
+ }
+ if (!c->op || strcmp(c->op, "=") != 0 || !c->property) {
+ return 0;
+ }
+ return (strcmp(c->property, "name") == 0 || strcmp(c->property, "qualified_name") == 0) ? 3 : 2;
+}
+
+static int cypher_expr_selectivity(const cbm_expr_t *e, const char *var) {
+ if (!e) {
+ return 0;
+ }
+ if (e->type == EXPR_CONDITION) {
+ return cypher_cond_selectivity(&e->cond, var);
+ }
+ if (e->type == EXPR_AND) {
+ int l = cypher_expr_selectivity(e->left, var);
+ int r = cypher_expr_selectivity(e->right, var);
+ return l > r ? l : r;
+ }
+ return 0; /* OR / NOT / XOR: no single equality to seed from */
+}
+
+static int cypher_node_selectivity(const cbm_node_pattern_t *n, const cbm_where_clause_t *w) {
+ int s = 0;
+ for (int i = 0; i < n->prop_count; i++) {
+ const char *k = n->props[i].key;
+ int v = (k && (strcmp(k, "name") == 0 || strcmp(k, "qualified_name") == 0)) ? 3 : 2;
+ if (v > s) {
+ s = v;
+ }
+ }
+ if (w && n->variable) {
+ int v = 0;
+ if (w->root) {
+ v = cypher_expr_selectivity(w->root, n->variable);
+ } else if (!w->op || strcmp(w->op, "AND") == 0) {
+ for (int i = 0; i < w->count; i++) {
+ int c = cypher_cond_selectivity(&w->conditions[i], n->variable);
+ if (c > v) {
+ v = c;
+ }
+ }
+ }
+ if (v > s) {
+ s = v;
+ }
+ }
+ if (s == 0 && n->label) {
+ s = 1;
+ }
+ return s;
+}
+
+static void cypher_plan_seed_from_selective_end(cbm_query_t *q) {
+ if (!q || q->pattern_count != 1 || !q->ret || (q->pattern_optional && q->pattern_optional[0])) {
+ return;
+ }
+ cbm_pattern_t *p = &q->patterns[0];
+ if (p->rel_count != 1 || p->node_count != 2) {
+ return;
+ }
+ if (cypher_node_selectivity(&p->nodes[1], q->where) <=
+ cypher_node_selectivity(&p->nodes[0], q->where)) {
+ return;
+ }
+ cbm_node_pattern_t tmp = p->nodes[0];
+ p->nodes[0] = p->nodes[1];
+ p->nodes[1] = tmp;
+ cbm_rel_pattern_t *r = &p->rels[0];
+ const char *inverted = NULL;
+ if (r->direction && strcmp(r->direction, "outbound") == 0) {
+ inverted = "inbound";
+ } else if (r->direction && strcmp(r->direction, "inbound") == 0) {
+ inverted = "outbound";
+ }
+ if (inverted) {
+ safe_str_free(&r->direction);
+ r->direction = heap_strdup(inverted);
+ }
+}
+
static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *project, int max_rows,
result_builder_t *rb) {
+ cypher_plan_seed_from_selective_end(q);
cbm_pattern_t *pat0 = &q->patterns[0];
/* Step 1: Scan initial nodes */
diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c
index a92befb08..3917a7a05 100644
--- a/src/daemon/ipc.c
+++ b/src/daemon/ipc.c
@@ -214,6 +214,31 @@ static void ipc_startup_gate_run(void) {
}
#endif
+#ifdef _WIN32
+static cbm_daemon_ipc_win_directory_create_hook_fn g_win_directory_create_hook_for_test;
+static void *g_win_directory_create_hook_context_for_test;
+#endif
+
+void cbm_daemon_ipc_win_directory_create_hook_set_for_test(
+ cbm_daemon_ipc_win_directory_create_hook_fn hook, void *context) {
+#ifdef _WIN32
+ g_win_directory_create_hook_context_for_test = context;
+ g_win_directory_create_hook_for_test = hook;
+#else
+ (void)hook;
+ (void)context;
+#endif
+}
+
+#ifdef _WIN32
+static void ipc_win_directory_create_hook_run(const wchar_t *path) {
+ cbm_daemon_ipc_win_directory_create_hook_fn hook = g_win_directory_create_hook_for_test;
+ if (hook) {
+ hook(path, g_win_directory_create_hook_context_for_test);
+ }
+}
+#endif
+
bool cbm_daemon_ipc_windows_legacy_names(const char *canonical_runtime_parent,
const char *instance_key,
char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP],
@@ -3994,6 +4019,16 @@ static char *wide_to_utf8(const wchar_t *value) {
return utf8;
}
+/* Record ": " as the validation detail, with the wide path
+ * rendered as UTF-8. One place owns the conversion buffer, so every refusal on
+ * the directory walk can name its component without each call site carrying
+ * its own allocation. */
+static void ipc_validation_detail_set_for_path(const wchar_t *path, const char *message) {
+ char *path_utf8 = wide_to_utf8(path);
+ ipc_validation_detail_set("%s: %s", path_utf8 ? path_utf8 : "", message);
+ free(path_utf8);
+}
+
static wchar_t *wide_copy(const wchar_t *value) {
if (!value) {
return NULL;
@@ -4861,13 +4896,28 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) {
return false;
}
bool created = CreateDirectoryW(runtime_dir, &security.directory_attributes) != 0;
- if (!created && GetLastError() != ERROR_ALREADY_EXISTS) {
+ DWORD create_error = created ? ERROR_SUCCESS : GetLastError();
+ if (!created && create_error != ERROR_ALREADY_EXISTS) {
+ char message[96];
+ (void)snprintf(message, sizeof(message),
+ "could not create the directory (Windows error %lu)",
+ (unsigned long)create_error);
+ ipc_validation_detail_set_for_path(runtime_dir, message);
win_security_destroy(&security);
return false;
}
DWORD attributes = GetFileAttributesW(runtime_dir);
if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 ||
(attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+ /* What already exists at this path is adopted only if it is a plain
+ * directory. Say which rule refused it: the walk in front of this
+ * function tolerates losing a creation race, so this check is what
+ * stands between that tolerance and a planted file or junction. */
+ const char *rule = attributes == INVALID_FILE_ATTRIBUTES ? "cannot be inspected"
+ : (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0
+ ? "exists but is not a directory"
+ : "is a reparse point (junction or symlink)";
+ ipc_validation_detail_set_for_path(runtime_dir, rule);
win_security_destroy(&security);
return false;
}
@@ -5021,8 +5071,35 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) {
DWORD attributes = GetFileAttributesW(path);
if (attributes == INVALID_FILE_ATTRIBUTES) {
DWORD error = GetLastError();
- ok = (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) &&
- CreateDirectoryW(path, &security.directory_attributes) != 0;
+ bool absent = error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
+ if (absent) {
+ ipc_win_directory_create_hook_run(path);
+ /* Several processes first-starting together all observe
+ * this component absent; one CreateDirectoryW wins and
+ * the rest get ERROR_ALREADY_EXISTS. Losing that race is
+ * not a failure -- the directory this process wanted now
+ * exists, exactly as if it had been there all along. It
+ * is NOT trusted for that: an ancestor still goes through
+ * win_directory_component_secure() below, and the final
+ * component through win_runtime_directory_secure(), which
+ * refuses a non-directory or reparse point and enforces
+ * owner and DACL. The POSIX walk tolerates EEXIST at the
+ * same point for the same reason. */
+ if (CreateDirectoryW(path, &security.directory_attributes) == 0) {
+ error = GetLastError();
+ absent = error == ERROR_ALREADY_EXISTS;
+ }
+ }
+ ok = absent;
+ if (!ok) {
+ /* Never a bare "(endpoint)": name the component and the
+ * Windows error that refused it. */
+ char message[96];
+ (void)snprintf(message, sizeof(message),
+ "could not create or inspect the directory (Windows error %lu)",
+ (unsigned long)error);
+ ipc_validation_detail_set_for_path(path, message);
+ }
}
/* Ancestors are observe-only and must already be secure. The
* final current-user directory is intentionally handled below by
@@ -5034,10 +5111,7 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) {
* at this walk position; the helper set the inner rule. */
char inner[384];
(void)snprintf(inner, sizeof(inner), "%s", ipc_validation_detail_buffer);
- char *component_utf8 = wide_to_utf8(path);
- ipc_validation_detail_set(
- "%s: %s", component_utf8 ? component_utf8 : "", inner);
- free(component_utf8);
+ ipc_validation_detail_set_for_path(path, inner);
}
}
}
diff --git a/src/daemon/ipc_internal.h b/src/daemon/ipc_internal.h
index 4b7cf1fd5..f481b7378 100644
--- a/src/daemon/ipc_internal.h
+++ b/src/daemon/ipc_internal.h
@@ -110,4 +110,15 @@ void cbm_daemon_ipc_posix_record_write_failure_set_for_test(int errno_value);
typedef void (*cbm_daemon_ipc_startup_gate_fn)(void *context);
void cbm_daemon_ipc_startup_gate_set_for_test(cbm_daemon_ipc_startup_gate_fn gate, void *context);
+/* Deterministic-interleaving seam for the Windows private-directory walk:
+ * fires after a path component was observed ABSENT and before this process
+ * tries to create it. A test plays the concurrent process that wins that
+ * creation, which pins the cold-start race (N processes first-starting
+ * against a runtime directory that does not exist yet) by construction
+ * instead of by thread timing. `path` is the NUL-terminated component path at
+ * the walk position. No-op off Windows. */
+typedef void (*cbm_daemon_ipc_win_directory_create_hook_fn)(const wchar_t *path, void *context);
+void cbm_daemon_ipc_win_directory_create_hook_set_for_test(
+ cbm_daemon_ipc_win_directory_create_hook_fn hook, void *context);
+
#endif /* CBM_DAEMON_IPC_INTERNAL_H */
diff --git a/src/foundation/compat_thread.c b/src/foundation/compat_thread.c
index 94af9f812..b86a7f3fb 100644
--- a/src/foundation/compat_thread.c
+++ b/src/foundation/compat_thread.c
@@ -2,7 +2,7 @@
* compat_thread.c — Portable thread, mutex, and aligned allocation.
*
* POSIX: thin wrappers around pthreads and posix_memalign.
- * Windows: CreateThread, CRITICAL_SECTION, _aligned_malloc.
+ * Windows: CreateThread, CRITICAL_SECTION, aligned allocation.
*/
#include "foundation/mem_events.h"
#include "foundation/constants.h"
@@ -279,12 +279,20 @@ void cbm_mutex_destroy(cbm_mutex_t *m) {
#ifdef _WIN32
int cbm_aligned_alloc(void **ptr, size_t alignment, size_t size) {
+#if defined(CBM_MEM_GLOBAL_OVERRIDE) && CBM_MEM_GLOBAL_OVERRIDE
+ *ptr = mi_malloc_aligned(size, alignment);
+#else
*ptr = _aligned_malloc(size, alignment);
+#endif
return *ptr ? 0 : -1;
}
void cbm_aligned_free(void *ptr) {
+#if defined(CBM_MEM_GLOBAL_OVERRIDE) && CBM_MEM_GLOBAL_OVERRIDE
+ mi_free(ptr);
+#else
_aligned_free(ptr);
+#endif
}
#else /* POSIX */
diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c
index c8aec01af..9f65c079a 100644
--- a/src/foundation/str_util.c
+++ b/src/foundation/str_util.c
@@ -310,6 +310,33 @@ bool cbm_validate_project_name(const char *name) {
return true;
}
+int cbm_utf8_trim_partial(char *buf) {
+ if (!buf) {
+ return 0;
+ }
+ int pos = (int)strlen(buf);
+ if (pos == 0) {
+ return 0;
+ }
+ /* Walk back over continuation bytes to the lead byte; drop the sequence
+ * when fewer continuation bytes follow it than its lead byte announces. */
+ int back = pos - 1;
+ int cont = 0;
+ while (back >= 0 && ((unsigned char)buf[back] & 0xC0) == 0x80) {
+ back--;
+ cont++;
+ }
+ if (back >= 0) {
+ unsigned char lead = (unsigned char)buf[back];
+ int need = lead >= 0xF0 ? 3 : lead >= 0xE0 ? 2 : lead >= 0xC0 ? 1 : 0;
+ if (need > cont) {
+ pos = back;
+ buf[pos] = '\0';
+ }
+ }
+ return pos;
+}
+
int cbm_json_escape(char *buf, int bufsize, const char *src) {
if (!buf || bufsize <= 0) {
return 0;
@@ -319,7 +346,8 @@ int cbm_json_escape(char *buf, int bufsize, const char *src) {
return 0;
}
int pos = 0;
- for (int i = 0; src[i] && pos < bufsize - JSON_NUL_RESERVE; i++) {
+ int i = 0;
+ for (; src[i] && pos < bufsize - JSON_NUL_RESERVE; i++) {
unsigned char c = (unsigned char)src[i];
if (c == '"' || c == '\\') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
@@ -356,5 +384,11 @@ int cbm_json_escape(char *buf, int bufsize, const char *src) {
}
}
buf[pos] = '\0';
+ /* A truncated output must never end inside a multibyte UTF-8 sequence: a
+ * callee cut mid-character persisted bytes SQLite cannot decode as text
+ * (2026-09-16 probe: nine CALLS rows across rust, java and typescript). */
+ if (src[i] != '\0') {
+ pos = cbm_utf8_trim_partial(buf);
+ }
return pos;
}
diff --git a/src/foundation/str_util.h b/src/foundation/str_util.h
index e0ff46057..afaeb6ba0 100644
--- a/src/foundation/str_util.h
+++ b/src/foundation/str_util.h
@@ -91,9 +91,16 @@ bool cbm_validate_project_name(const char *name);
} \
} while (0)
+/* Drop a trailing INCOMPLETE UTF-8 sequence from a NUL-terminated buffer in
+ * place (a lead byte whose continuation bytes were cut off). Returns the new
+ * length. Complete sequences and ASCII are left untouched. Use after any
+ * byte-count truncation of text that will be stored or serialised. */
+int cbm_utf8_trim_partial(char *buf);
+
/* Escape a string for safe embedding in JSON: escapes " \ and control chars.
* Writes into buf (including NUL). Returns number of chars written (excl NUL).
- * If buf is too small, output is truncated but always NUL-terminated. */
+ * If buf is too small, output is truncated but always NUL-terminated, and it
+ * never ends inside a multibyte UTF-8 sequence. */
int cbm_json_escape(char *buf, int bufsize, const char *src);
#endif /* CBM_STR_UTIL_H */
diff --git a/src/graph_buffer/graph_buffer.c b/src/graph_buffer/graph_buffer.c
index 24553657d..e55eb28f0 100644
--- a/src/graph_buffer/graph_buffer.c
+++ b/src/graph_buffer/graph_buffer.c
@@ -39,6 +39,7 @@ enum {
#include "foundation/mem_core.h"
#include
+#include
#include
#include // int64_t
#include
@@ -1841,6 +1842,32 @@ static void log_dump_summary(int node_count, int edge_count) {
cbm_log_info("gbuf.dump", "nodes", b1, "edges", b2);
}
+/* A dump that published nothing gets exactly one signature in the log.
+ * Without it the writer's return code is the only evidence, and it reaches
+ * the user as a generic pipeline failure that blames their repository
+ * (#1620, #2001).
+ *
+ * failure_errno is read by the caller at the point of failure: the value is
+ * only meaningful before the next library call. Zero means the failure did
+ * not originate at the publish boundary — an append that failed many calls
+ * earlier — and the field is omitted rather than claim a reason this record
+ * does not have. */
+static void log_dump_failed(int rc, int failure_errno, int node_count, int edge_count) {
+ char b1[CBM_SZ_16];
+ char b3[CBM_SZ_16];
+ char b4[CBM_SZ_16];
+ snprintf(b1, sizeof(b1), "%d", rc);
+ snprintf(b3, sizeof(b3), "%d", node_count);
+ snprintf(b4, sizeof(b4), "%d", edge_count);
+ if (failure_errno == 0) {
+ cbm_log_error("gbuf.dump_failed", "rc", b1, "nodes", b3, "edges", b4);
+ return;
+ }
+ char b2[CBM_SZ_16];
+ snprintf(b2, sizeof(b2), "%d", failure_errno);
+ cbm_log_error("gbuf.dump_failed", "rc", b1, "errno", b2, "nodes", b3, "edges", b4);
+}
+
static void free_dump_resources(char **url_paths, char **local_names, int edge_count,
CBMDumpEdge *dump_edges, CBMDumpNode *dump_nodes,
int64_t *temp_to_final) {
@@ -1896,6 +1923,7 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
int64_t max_temp_id = gb->next_id;
int64_t *temp_to_final = cbm_calloc(CBM_MEM_CLASS_DUMP, (size_t)max_temp_id * sizeof(int64_t));
if (!temp_to_final) {
+ log_dump_failed(CBM_NOT_FOUND, errno, 0, 0);
return CBM_NOT_FOUND;
}
@@ -1926,6 +1954,7 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
* uninitialized budget from ever triggering the free). */
cbm_db_writer_t *w = cbm_writer_open(path);
if (!w) {
+ log_dump_failed(CBM_NOT_FOUND, errno, node_idx, 0);
cbm_free(CBM_MEM_CLASS_DUMP, src_nodes);
cbm_free(CBM_MEM_CLASS_DUMP, dump_nodes);
cbm_free(CBM_MEM_CLASS_DUMP, temp_to_final);
@@ -1980,12 +2009,19 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
int frc = cbm_writer_finalize(w, gb->project, gb->root_path, indexed_at, dump_nodes, node_idx,
dump_edges, edge_idx, gb->dump_vectors, gb->dump_vector_count,
gb->dump_token_vecs, gb->dump_token_vec_count);
+ /* Read errno before anything else can touch it: the profiling macro
+ * below is already one library call away from losing the reason. */
+ int finalize_errno = errno;
CBM_PROF_END_N("dump", "6_write_db_finalize", t_finalize, node_idx + edge_idx);
if (rc == 0) {
rc = frc;
}
- log_dump_summary(node_idx, edge_idx);
+ if (rc != 0) {
+ log_dump_failed(rc, finalize_errno, node_idx, edge_idx);
+ } else {
+ log_dump_summary(node_idx, edge_idx);
+ }
free_dump_resources(url_paths, local_names, edge_idx, dump_edges, dump_nodes, temp_to_final);
cbm_free(CBM_MEM_CLASS_DUMP, src_nodes);
return rc;
diff --git a/src/main.c b/src/main.c
index 9b91f205c..65e041217 100644
--- a/src/main.c
+++ b/src/main.c
@@ -675,13 +675,7 @@ static bool client_start_parent_watchdog(DWORD initial_ppid) {
/* ── CLI mode ───────────────────────────────────────────────────── */
-#define CLI_USAGE \
- "Usage: codebase-memory-mcp cli [--quiet] [--progress] [--verbose] [--json] " \
- "[json_args]\n" \
- " --quiet Show errors only; cannot combine with --progress or outer --verbose\n" \
- " --progress Show lifecycle progress even when stderr is redirected\n" \
- " --verbose Include informational logs (preserves CBM_LOG_LEVEL=debug)\n" \
- " --json Print the raw MCP result envelope\n"
+#define CLI_USAGE CBM_CLI_USAGE /* `cli --help`; wording lives in cli.h (#2102) */
/* Extract text content from MCP tool result envelope and print it.
* MCP results: {"content":[{"type":"text","text":"..."}],"isError":...}
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 39aaf0dcc..75ff8e396 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -63,6 +63,7 @@ enum {
#include "cli/cli.h"
#include "watcher/watcher.h"
#include "foundation/mem.h"
+#include "foundation/mem_core.h"
#include "foundation/diagnostics.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
@@ -3762,6 +3763,8 @@ enum {
BM25_BIND_OFFSET = 4,
BM25_BIND_INNER = 5,
BM25_BIND_FILE = 6,
+ BM25_BIND_LABEL = 7,
+ BM25_BIND_EXACT = 8,
BM25_SQL_AUTO_LEN = -1,
/* Inner FTS5 candidate cap. SQLite can early-terminate a plain FTS5 query
* (no JOIN/WHERE on outer table) of the form:
@@ -3878,7 +3881,7 @@ static void bm25_output_rows_free(bm25_output_row_t *rows, int count) {
free(rows[i].label);
free(rows[i].file_path);
}
- free(rows);
+ cbm_free(CBM_MEM_CLASS_OTHER, rows);
}
static void bm25_lines_str(char *out, size_t size, int start, int end) {
@@ -4019,8 +4022,8 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total,
* 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. */
static char *bm25_search(cbm_store_t *store, const char *project, const char *query,
- const char *file_pattern, int limit, int offset, bool tree_format,
- size_t max_output_bytes) {
+ const char *file_pattern, const char *label, int limit, int offset,
+ bool tree_format, size_t max_output_bytes) {
sqlite3 *db = cbm_store_get_db(store);
if (!db) {
return NULL;
@@ -4046,9 +4049,18 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
* because no outer predicate blocks it. We fetch BM25_INNER_LIMIT top candidates
* from the FTS5 index, then join/filter/boost only those rows. bm25() returns a
* NEGATIVE score (lower = more relevant). */
+ /* Exact-name tier (2026-09-16 probe): a definition whose NAME is the query
+ * outranks every partial hit. BM25 term frequency otherwise rewards a long
+ * test-method name that repeats the token — `table references table with
+ * same name` — over the `Table` class itself, and the label tiers below
+ * then push the class's own methods above it (Method 10 > Class 5). The
+ * definition the reader asked for by name comes first; case-insensitive
+ * exact spelling comes next; everything else keeps its BM25 order. */
const char *sql =
"SELECT n.id, n.label, n.name, n.qualified_name, n.file_path, n.start_line, n.end_line, "
" (fts.base_rank "
+ " - CASE WHEN n.name = ?8 THEN 30.0 "
+ " WHEN lower(n.name) = lower(?8) THEN 20.0 ELSE 0.0 END "
" - CASE WHEN n.label IN ('Function','Method') THEN 10.0 "
" WHEN n.label = 'Route' THEN 8.0 "
" WHEN n.label IN (" CBM_SQL_TYPE_LIKE_LABELS ") THEN 5.0 "
@@ -4070,6 +4082,10 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
* must be changed together or results desynchronise from counts. */
" AND n.label NOT IN ('File','Folder','Variable','Project') "
" AND (?6 IS NULL OR n.file_path LIKE ?6) "
+ /* The caller's label filter applies in query mode exactly as it does
+ * in the structural mode (2026-09-16 probe: `label=Class` was ignored
+ * here and five Methods came back). MIRRORED in the count query. */
+ " AND (?7 IS NULL OR n.label = ?7) "
/* rank ties are common (boosted floats) — the id tie-break makes
* offset pages contractually stable across calls. */
"ORDER BY rank, n.id "
@@ -4090,6 +4106,12 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
} 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);
/* 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. */
@@ -4107,6 +4129,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
* not describe the rows returned. */
" AND n.label NOT IN ('File','Folder','Variable','Project')"
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
+ " AND (?7 IS NULL OR n.label = ?7)"
")";
sqlite3_stmt *cs = NULL;
if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) == SQLITE_OK) {
@@ -4121,6 +4144,12 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
} 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 (sqlite3_step(cs) == SQLITE_ROW) {
total = sqlite3_column_int(cs, 0);
}
@@ -4149,14 +4178,15 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
}
int row_cap = limit > 0 ? limit : BM25_DEFAULT_LIMIT;
- bm25_output_row_t *rows = calloc((size_t)row_cap, sizeof(*rows));
+ bm25_output_row_t *rows =
+ (bm25_output_row_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)row_cap * sizeof(*rows));
int row_count = 0;
while (rows && row_count < row_cap && sqlite3_step(stmt) == SQLITE_ROW) {
const char *qn = (const char *)sqlite3_column_text(stmt, BM25_COL_QN);
- const char *label = (const char *)sqlite3_column_text(stmt, BM25_COL_LABEL);
+ const char *row_label = (const char *)sqlite3_column_text(stmt, BM25_COL_LABEL);
const char *file = (const char *)sqlite3_column_text(stmt, BM25_COL_FILE);
rows[row_count].qualified_name = heap_strdup(qn ? qn : "");
- rows[row_count].label = heap_strdup(label ? label : "");
+ rows[row_count].label = heap_strdup(row_label ? row_label : "");
rows[row_count].file_path = heap_strdup(file ? file : "");
rows[row_count].start_line = sqlite3_column_int(stmt, BM25_COL_START);
rows[row_count].end_line = sqlite3_column_int(stmt, BM25_COL_END);
@@ -5089,9 +5119,11 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
}
if (query && query[0]) {
char *q_file_pattern = cbm_mcp_get_string_arg(args, "file_pattern");
- char *bm25_json = bm25_search(store, project, query, q_file_pattern, limit, offset,
+ char *q_label = cbm_mcp_get_string_arg(args, "label");
+ char *bm25_json = bm25_search(store, project, query, q_file_pattern, q_label, limit, offset,
!json_format, max_output_bytes);
free(q_file_pattern);
+ free(q_label);
if (bm25_json) {
free(query);
free(project);
@@ -11062,6 +11094,16 @@ static bool project_db_is_servable(const char *project, const char *db_path) {
return servable;
}
+/* The three heap strings handle_index_repository owns from
+ * cbm_mcp_get_string_arg / resolved_repo_path_from_project_arg. One release
+ * point keeps the dozen early-return paths in step; free(NULL) is a no-op, so
+ * a path may pass NULL for an argument it never obtained. */
+static void index_args_free(char *repo_path, char *mode_str, char *name_override) {
+ free(repo_path);
+ free(mode_str);
+ free(name_override);
+}
+
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
@@ -11074,15 +11116,12 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
}
if (!repo_path) {
- free(mode_str);
- free(name_override);
+ index_args_free(NULL, mode_str, name_override);
return cbm_mcp_text_result("repo_path is required", true);
}
if (!resolve_session_repo_path(srv, &repo_path)) {
- free(mode_str);
- free(name_override);
- free(repo_path);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result("failed to resolve repo_path", true);
}
@@ -11102,26 +11141,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
if (repo_path && repo_path[0] &&
!cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(),
allowed_root, boundary_err, sizeof(boundary_err))) {
- free(mode_str);
- free(name_override);
- free(repo_path);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result(boundary_err, true);
}
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
- free(mode_str);
char *result = handle_cross_repo_mode(srv, repo_path, name_override, args);
- free(name_override);
- free(repo_path);
+ index_args_free(repo_path, mode_str, name_override);
return result;
}
cbm_index_resource_policy_t resource_policy;
char policy_error[CBM_SZ_256] = {0};
if (!load_index_policy(srv, args, &resource_policy, policy_error, sizeof(policy_error))) {
- free(mode_str);
- free(name_override);
- free(repo_path);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result(policy_error, true);
}
@@ -11133,9 +11166,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
worker_args ? srv->index_executor(srv->index_executor_context, repo_path, worker_args)
: NULL;
free(worker_args);
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return coordinated ? coordinated
: cbm_mcp_text_result(
"daemon index coordinator could not start the operation", true);
@@ -11148,9 +11179,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *mutation_project =
cbm_project_name_from_path(name_override && name_override[0] ? name_override : repo_path);
if (!mutation_project) {
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result("could not resolve index project name", true);
}
@@ -11163,24 +11192,18 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *worker_args = index_args_with_repo_path(args, repo_path, &resource_policy);
if (!worker_args) {
free(mutation_project);
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result("failed to prepare supervised index request", true);
}
char *supervised = index_run_supervised(srv, worker_args);
free(worker_args);
if (supervised) {
free(mutation_project);
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return supervised;
}
free(mutation_project);
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result(
"index supervision failed before a contained worker could start; no "
"in-process fallback was attempted",
@@ -11189,18 +11212,14 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
if (!mcp_project_mutation_begin(srv, mutation_project)) {
free(mutation_project);
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result("index operation blocked by another mutation for this project",
true);
}
if (mcp_request_cancelled(srv)) {
mcp_project_mutation_end(srv, mutation_project);
free(mutation_project);
- free(repo_path);
- free(mode_str);
- free(name_override);
+ index_args_free(repo_path, mode_str, name_override);
return cbm_mcp_text_result("index operation cancelled for this request", true);
}
diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c
index c9744dda2..84958954c 100644
--- a/src/pipeline/pass_calls.c
+++ b/src/pipeline/pass_calls.c
@@ -626,15 +626,29 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
* reintroduce the #592/#606 false-edge class for .ets files. */
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
- lang == CBM_LANG_ARKTS;
+ lang == CBM_LANG_ARKTS ||
+ /* Files whose calls are embedded JS/TS (