625 Commits
Author SHA1 Message Date
Martin Vogel b133a204d2 release: bump pkg/npm + pkg/pypi to v0.7.0
The release.yml workflow publishes both wrappers as-is from the repo;
they were still pinned to 0.6.1, which made the npm publish step try to
republish v0.6.1 instead of v0.7.0.
0.7.0 v0.7.0
2026-05-30 01:15:58 +02:00
Martin Vogel 82d5c841e6 fix(parallel): destroy thread parser before slab reclaim in LSP-cross loop
The LSP-cross loop in parallel_resolve called cbm_slab_reclaim() without
first destroying the thread-local tree-sitter parser, violating the
documented slab contract. The parser's lexer holds slab-allocated state
(notably lexer.included_ranges, a 24-byte allocation that lands in the
≤64B slab bucket); reclaiming the slab leaves that pointer dangling.

Workers don't trip on it because they don't reparse afterward, but the
main thread participates in cbm_parallel_for and any later cbm_extract_file
on the main thread — including the sequential pass_definitions in the
incremental pipeline — hits a heap-use-after-free in ts_lexer_goto on
macOS-ASan.

Two-part fix:
- pass_parallel.c: destroy the thread parser before cbm_slab_reclaim() in
  the per-file LSP-cross loop, matching the worker extract loop.
- pass_definitions.c: defensively drop the thread parser at pass entry so
  the incremental sequential path cannot inherit stale state from any
  prior run.
2026-05-29 23:11:52 +02:00
Martin Vogel 297003aab4 fix(test): use __SANITIZE_ADDRESS__ alone for sanitizer detection (gcc-portable)
GCC's preprocessor doesn't short-circuit 'defined(__has_feature) && __has_feature(...)' — it tries to parse the call syntax of the undefined token and errors with 'missing binary operator before token "("'. Modern GCC and Clang both define __SANITIZE_ADDRESS__ under -fsanitize=address, so use that single check.
2026-05-29 22:30:03 +02:00
Martin Vogel d5211f6ae0 chore: remove release-notes file (consumed via workflow input, not a repo doc) 2026-05-29 22:26:36 +02:00
Martin Vogel 130b707b35 release: v0.7.0 notes; make LSP-bench tests sanitizer-aware
Free the result before the time-budget assert (no leak on miss) and scale the budget ×10 when ASan/UBSan is active, so the perf benches run cleanly in the release CI (skip_perf=false) instead of only being gated by CBM_SKIP_PERF in dry-run.
2026-05-29 21:58:54 +02:00
Martin Vogel 90e85bb10c fix: pad source read buffers in all parse passes (tree-sitter lookahead)
Same fix as read_file in pass_definitions: the calls/usages/semantic/k8s/lsp_cross passes each read a source file into a malloc'd buffer and re-parse it, so they hit the same tree-sitter lexer over-read past EOF under ASan. Over-allocate by 16 and zero the tail in every reader.
2026-05-29 06:00:44 +02:00
Martin Vogel c023931014 fix: pad source read buffer for tree-sitter lookahead (ASan over-read)
read_file allocated size+1, but tree-sitter's lexer peeks a few bytes past the final UTF-8 char (ts_lexer_goto), reading past the buffer end. Under ASan this is a heap-buffer-overflow (intermittently flagged in the incremental suite on macOS); in production it is a benign over-read of adjacent heap. Over-allocate by 16 bytes and zero the tail so the lookahead stays in-bounds.
2026-05-29 05:57:19 +02:00
Martin Vogel 3bcbc59136 test: size path_alias temp-dir buffer for cbm_mkdtemp (Windows)
cbm_mkdtemp rewrites the template in place to the translated %TEMP% path on Windows, which overflowed the 23-byte 'char tmpl[]' literal (ASan stack-buffer-overflow). Use a 256-byte buffer as the cbm_mkdtemp contract requires (matching the other test callers).
2026-05-29 04:20:20 +02:00
Martin Vogel 7bf3aa86a0 test: use portable temp dir/paths so Windows unit tests pass
Four tests hardcoded POSIX /tmp/ paths that don't exist on Windows: test_path_alias used raw mkdtemp("/tmp/...") and the store pragma/checkpoint tests built /tmp/*.db directly. Switch to cbm_mkdtemp (translates /tmp/ -> %TEMP% on Windows) and cbm_tmpdir() (matching test_store_bulk). build/smoke/soak-windows already pass; this makes the Windows unit-test job green too.
2026-05-29 03:37:56 +02:00
Martin Vogel ef25ca0727 style: wrap cbm_strndup call in path_alias.c for clang-format-20
The strndup->cbm_strndup rename pushed the line past the column limit; reflow to satisfy the clang-format-20 lint check.
2026-05-29 02:54:00 +02:00
Martin Vogel dcd9ea61d4 fix(win): make system_info.c and path_alias.c compile on Windows
system_info.c routed Windows into the '#else /* Linux */' detection branch (sysconf/sysinfo, unavailable on msys2-clang) because the platform chain only special-cased __APPLE__/BSD; restrict the Linux branch with '#elif !defined(_WIN32)' so Windows uses detect_system_windows() exclusively.

path_alias.c used raw strndup (undeclared on msys2-clang); switch to the cbm_strndup compat shim and include foundation/compat.h.
2026-05-29 02:10:50 +02:00
Martin Vogel a805668ef8 test: gate LSP benchmarks behind CBM_SKIP_PERF; fix Windows test portability
The py/cs LSP benchmark tests have time-budget asserts (<150ms / <200ms) that ASan+UBSan blow on CI (750-900ms on arm64), so they bailed before cbm_free_result and LeakSanitizer flagged the leaked arena/parser. Gate them behind CBM_SKIP_PERF (set by the dry-run) like the incremental perf tests, so they run only when perf is enabled.

Fix Windows (msys2-clang) compile errors in test code by using the existing compat shims: setenv/unsetenv -> cbm_setenv/cbm_unsetenv (test_store_pragmas, test_ts_lsp), strndup -> cbm_strndup and 2-arg mkdir -> cbm_mkdir (test_path_alias), and guard the utimensat mtime-bump in test_pipeline behind #ifndef _WIN32.
2026-05-29 01:30:11 +02:00
Martin Vogel d1b676fc0d fix(deps): bump ws to 8.21.0 (CVE-2026-45736, GHSA-58qx-3vcg-4xpx)
ws 8.19.0 had a moderate uninitialized-memory-disclosure issue in websocket.close() (fixed in 8.20.1). Transitive dev dependency; the existing ^8.18.0 range already permits the fix, so this is a lockfile-only bump. npm audit now reports 0 vulnerabilities.
2026-05-29 00:36:24 +02:00
Martin Vogel 7a6a8cb7a5 style: apply clang-format-20 across tree
Format drift had accumulated across recent commits (the dry-run lint enforces clang-format-20, which had not run on these commits yet). No functional changes — whitespace/layout only, verified by a clean cppcheck + clang-format + NOLINT lint-ci pass.
2026-05-29 00:34:18 +02:00
Martin Vogel b5f086974d fix(ci): resolve cppcheck null-deref + variable-scope and security-audit findings
cppcheck (warning+style, error-exitcode=1): guard cbm_fqn_compute against a NULL project/rel_path (ctunullpointer reachable from cbm_extract_file), and narrow prefix_len/suffix_len (path_alias) and ft_count (pass_githistory) to the scopes that use them.

security-audit.sh: the mcp.c file-read count grew to 13 (search/ADR/Windows-support reads — all path-contained or transport reads, audited, no new exfiltration surface); bump the reviewed maximum and document the update-check + request-body reads. Allow the diagnostics.c atomic metrics dump (.tmp+rename) in the file-write scan, and allow-list the sqlite WAL-checkpoint doc URL referenced in a store.c comment.
2026-05-29 00:30:17 +02:00
Martin Vogel bb1411620a refactor(pipeline): always run LSP; remove opt-in advanced mode
The per-file and cross-file LSP type-aware call/usage resolution now runs in every index mode (full/moderate/fast) instead of being gated behind a separate CBM_MODE_ADVANCED. Remove the mode from both index-mode enums, the g_lsp_enabled toggle and its save/restore dance, and the per-mode gating in discover, pipeline and the incremental path.

Now that the LSP walkers are O(n), the callee-attribution accuracy (e.g. ~91k call overrides on roslyn, ~60k on kubernetes) is worth the latency in every mode. Fast mode still filters files and skips similarity/semantic edges, so it stays faster than full while now also benefiting from LSP resolution.

A 'mode':'advanced' request falls back to the full default — identical behaviour to the old advanced mode, so existing callers are unaffected. This also fixes the test_incremental.c failures, which were asserting LSP-resolved results that the gated incremental path never produced.
2026-05-28 19:06:09 +02:00
Martin Vogel 1b9d9c136f perf(lsp): make per-file and cross-file LSP walkers O(n); fix two crashes
The advanced-mode LSP passes walked the AST with the same O(i) ts_node_child(node, i) idiom, so a wide root made each per-file pass quadratic — the TS fixture reallyLargeFile.ts took ~16000s. Add a shared cbm_lsp_collect_children() helper (lsp_node_iter.h) and convert the entry and recursive walkers across the TypeScript, C, C#, PHP and Go LSP back-ends to a single O(n) cursor pass. The file now indexes in ~28s; full advanced-mode runs: TS 100s, roslyn 46s, kubernetes 51s, postgres 8s, WordPress 7s.

Also fix two latent crashes exposed once the resolve phase actually ran: process_node indexed the NULL-terminated param_types[] by the call's argument count, reading past the terminator when a call passed more args than the callee declares; and return_type_of() built a tuple via cbm_type_tuple(NULL, ...), passing a NULL arena — it now threads ctx->arena.

This also includes the Tier 2 cross-LSP registry plumbing (shared read-only base registry with per-file overlay/fallback) the walkers build on.

All LSP unit suites pass: TS, C 737/737, C# 60/60, PHP 280/280, Go 55/55.
2026-05-28 13:56:08 +02:00
Martin Vogel 01bdba2a3f perf(extract): eliminate O(n^2) AST traversal on wide-flat files
tree-sitter's ts_node_child(node, i) is O(i) — it walks the child iterator from the first child on every call — so the common 'for (i=0; i<count; i++) ts_node_child(node, i)' idiom is O(n^2) over a node's children. On a file whose top level is very wide (e.g. a 580k-line fixture parsed as hundreds of thousands of sibling comment nodes) this made extraction run for over an hour.

Add ts_nstack_push_children(), which enumerates a node's children in a single O(n) cursor pass, and use it in the whole-tree DFS walkers (calls, channels, env-accesses, imports, type-assigns). Convert the module-level variable walk to a cursor too, and add cbm_is_module_level_p() so callers that already hold the parent node skip the O(n) ts_node_parent() rescan.

Indexing microsoft/TypeScript drops from ~5100s to ~50s with identical graph output.
2026-05-28 13:55:48 +02:00
Martin Vogel 7d03632597 perf(lsp): C# Tier 2 — pre-built per-language cross-LSP registry
Same clean def-driven pattern as C/Go. Extracts cs_register_lsp_defs,
adds cbm_cs_build_cross_registry (filters CBM_LANG_CSHARP) +
cbm_run_cs_lsp_cross_with_registry. resolve_worker dispatches
CBM_LANG_CSHARP to the shared registry; pipeline.c builds
cross_registries.cs; pass_lsp_cross.h includes cs_lsp.h.

C# cross-LSP is def-driven (no per-file AST field collection), so the
shared registry yields identical resolution to the per-file build.

roslyn bench (17,916 C# files, ADVANCED): single shared registry
(lsp_cross_prepare=234ms, 260,814 defs, zero slow files), 91,089 LSP
overrides applied, 58.6s total. All 60 test_cs_lsp tests pass; serilog
sanity exact (2,627n/7,112e). No SEGV/ASan.
2026-05-26 17:06:49 +02:00
Martin Vogel ef054ebd4e perf(lsp): C/C++ Tier 2 — pre-built per-language cross-LSP registry
Brings C/C++/CUDA to Go's optimization level. Extracts the cross-LSP
registration loop into c_register_lsp_defs (shared by per-file and
Tier 2 paths), adds:
  cbm_c_build_cross_registry  — builds ONE project-wide registry from
    all C-family defs (filters by lang) + both C and C++ stdlibs,
    finalized, shared READ-ONLY across resolve workers.
  cbm_run_c_lsp_cross_with_registry — per-file resolve using the
    shared registry; no per-file registry rebuild.

C cross-LSP is DEF-DRIVEN (fields/returns/embeds come from the def
strings, no per-file AST field collection), so the shared registry
produces identical resolution to the per-file build — zero quality
loss. resolve_worker dispatches CBM_LANG_C/CPP/CUDA to the new path;
pipeline.c builds cross_registries.c; pass_lsp_cross.h includes c_lsp.h.

postgres bench (4,967 files, ADVANCED mode): 628 C files share one
pre-built registry (lsp_cross_prepare=17ms, zero slow files), 44 LSP
overrides, +9 edges vs FULL. All 200 test_c_lsp tests pass. No
SEGV/ASan. Total pipeline overhead vs FULL: +5.2%.
2026-05-26 16:49:12 +02:00
Martin Vogel b61861eb5a feat(pipeline): add advanced index mode — gate ALL LSP behind it
New CBM_MODE_ADVANCED=3. Both per-file LSP (type-aware resolution
during extract) and the cross-file LSP pass (during resolve) are now
OPT-IN: FULL and below run tree-sitter extraction + registry textual
resolution only; ADVANCED adds the full LSP layer.

Rationale: profiling showed LSP changes the kubernetes graph by only
~0.01% of edges (it mostly re-confirms textual resolution) while
costing significant memory + time. Making it opt-in keeps the default
FULL path lean (3.0 GB vs 5.6 GB peak RSS on K8s) without meaningfully
worse output, and lets users pay for LSP precision only when wanted.

Mechanism:
- cbm.c: g_lsp_enabled atomic toggle wraps the entire per-file LSP
  block (Go/C/C++/PHP/Python/TS/JS/C#) + the C/C++ preprocessor-pass
  LSP call. Default true so ad-hoc extraction / tests keep historical
  behaviour. cbm_set_lsp_enabled / cbm_get_lsp_enabled in cbm.h.
- pipeline.c: sets the toggle (save→set→restore so it never leaks to
  other callers) from p->mode before extraction; gates the cross-LSP
  all_defs collection + registry build on advanced; gates the
  sequential lsp_cross dispatch. Fixed the moderate_only comparison
  (== FAST, not > MODERATE) so ADVANCED is not treated as lighter.
- pipeline_incremental.c: same toggle save/set/restore.
- discover.h/.c: added CBM_MODE_ADVANCED to discover's enum + all 4
  filter comparisons treat ADVANCED like FULL (full discovery, no
  fast-filtering). Without this, ADVANCED (numerically 3) was
  mis-detected as fast-mode and dropped ~half the files.
- mcp.c: parse mode "advanced" + tool schema enum/description.

Dual-mode K8s bench: FULL 46.7s (lsp_overrides=0, 3.0 GB) vs
ADVANCED 48.8s (lsp_overrides=80,818, 5.6 GB). Graphs within 0.01%.
All 3,615 tests pass.
2026-05-26 16:48:58 +02:00
Martin Vogel 2ee0c68d4a perf(foundation): swap CBMHashTable internals to Verstable v2.2.1
Replaces the prior Robin-Hood open-addressing implementation with
Verstable (https://github.com/JacksonAllan/Verstable), a 2024 state-
of-the-art C hash table. The public API in foundation/hash_table.h
is UNCHANGED — CBMHashTable is now an opaque type and all 14
production callers (graph_buffer, registry, pipeline passes,
service_patterns, watcher, semantic, etc.) get the new impl
transparently. str_intern.c keeps its specialised (ptr+len) hash —
different semantics, not a clean swap target.

Why: per-sub-phase profiling pinpointed cbm_pipeline_lsp_target_node
and cbm_registry_resolve as the dominant resolve_calls cost. Both
ultimately hit cbm_ht_get; both run millions of times on a kubernetes
index. Verstable's key innovations target exactly this:

  * 4-bit hash-fragment metadata in a uint16_t sidecar array lets
    chain walks skip most key comparisons WITHOUT touching the
    bucket payload (cache wins on hot lookups).
  * Quadratic probing with per-bucket displacement chains (not linear
    Robin-Hood psl).
  * Tombstone-free deletion that moves at most one existing key.

Vendored at internal/cbm/vendored/verstable/verstable.h (2069 lines,
84KB, MIT). Wrapped as a const char* → void* template inside
hash_table.c. The header struct is opaque (forward declaration only);
audited the entire tree — no caller accesses internals or takes
sizeof(CBMHashTable), so opacity is safe.

Test refactor: test_hash_table.c had two impl-specific tests:
  * ht_robin_hood_bounded_psl — Robin-Hood specific; removed.
    ht_stress_10000 covers the functional equivalent.
  * ht_power_of_two_capacity — renamed ht_create_small_capacity,
    replaced struct probes with a functional insert+lookup check.
ht_create_capacity_zero/one similarly de-internalised.

Benchmark on kubernetes (12 workers, 20,650 files):
- parallel_resolve wall: 36,376 -> 27,217 ms (-25.2%)
- pipeline.done total:   61,485 -> 51,745 ms (-15.8%)
- registry_build:        ~1,000 -> 779 ms (faster — registry uses
                         cbm_ht_set heavily during build)
- All 3,615 tests pass; hash_table suite 100% green
- ASan/UBSan/SEGV: none
- Peak RSS: ~6.83 GB
- Graph: nodes 297,082 -> 297,102 (+20, 0.007%), edges 1,155,444 ->
  1,154,025 (-1,419, -0.12%) — within tolerance; minor delta likely
  from hash-iteration-order differences affecting tie-breaks in
  resolution. Sanity (zod/serilog): nodes exact, edges within 0.1%.

Cumulative trajectory from broken-state baseline ~35 min on K8s ->
51.7s wall = ~40x total speedup.
2026-05-21 22:52:16 +02:00
Martin Vogel 01e49a12bf perf(registry): per-file full-result cache for cbm_registry_resolve
THE big win. Pinpoint-instrumented profiling showed 98.7% of
resolve_calls CPU on kubernetes (881s out of 893s) lived inside the
'resolve' sub-step — cbm_pipeline_lsp_target_node when LSP hits,
cbm_registry_resolve strategy chain when it misses. The LSP path
is fast; the strategy chain isn't.

cbm_registry_resolve's strategy chain (import_map -> same_module ->
unique_name -> suffix_match+scoring) is deterministic for a given
(callee_name, module_qn) pair. module_qn is CONSTANT within a file,
so within one file the same callee_name resolves identically every
time. K8s files have ~200 calls but only ~50 unique callee_names —
so ~75% of strategy-chain walks are redundant.

Added a TLS hash cache (_resolve_cache, registry.c) keyed by
callee_name with value cbm_resolution_t. cbm_registry_resolve checks
the cache first; on miss it runs the full chain and caches BOTH
positive and negative results (caching the negative is just as
valuable — same name asks the same question).

API in pipeline.h:
  void cbm_registry_resolve_cache_begin(int estimated_capacity);
  void cbm_registry_resolve_cache_end(void);

Two smaller chunks landed in the same commit:
- src/pipeline/registry.c: per-file import-map prefix -> module-qn
  hash (cbm_registry_import_map_cache_*). resolve_import_map's
  linear strcmp scan becomes O(1). Flat impact in this run because
  the LSP path bypasses cbm_registry_resolve for most K8s calls,
  but useful for projects without strong LSP coverage.
- src/pipeline/pass_parallel.c: reuse lsp_target as target_node
  when try_field_type_hint didn't override the QN. Saves 1
  cbm_gbuf_find_by_qn lookup per LSP-resolved call.

Also added 6 sub-counters inside resolve_file_calls (time_ns_rc_*)
logged as parallel.resolve.calls_breakdown / breakdown2 to keep
durable telemetry on which sub-step dominates.

Benchmark on kubernetes (12 workers, 20,650 files):
- parallel_resolve wall: 90,531 -> 36,376 ms (-59.8% vs C2; -64.8%
  vs original 103,281 baseline)
- resolve_calls CPU:     893,657 -> 277,556 ms (-68.9%)
- 'resolve' sub-counter: 881,650 -> 266,521 ms (-69.8%) ← target hit
- resolve_usages CPU bonus: 10,986 -> 5,594 ms (-49.1%, secondary
  effect — fewer ambiguous resolutions to scrub)
- Total pipeline.done:   115,918 -> 61,485 ms (-47%)
- User CPU total:        953.7s -> 375.1s (-60.7%)
- Peak RSS: +430 MB for cached cbm_resolution_t structs (~12 live
  caches at a time)
- Tests: 3616/0
- Graph: nodes 297,082 EXACT match, edges +/- 0.2%
- zod/serilog sanity: nodes exact, edges within tolerance

Effective hit rate: predicted 75%, measured ~70%. The ~30% true
cache misses (unique callee_names per file) still run the full
chain — next-tier optimizations: widen scope to per-worker so
common names like 'Get'/'Add' are computed once per worker not
once per file, or pre-filter candidate lists by import reachability
at file entry to shrink the chain's inner cost.

Cumulative trajectory from original broken state: ~35 min K8s cold
index -> 63.6s wall = ~33x total speedup.
2026-05-21 17:01:03 +02:00
Martin Vogel 1af1dd8688 perf(service_patterns): per-worker TLS cache for cbm_service_pattern_match
cbm_service_pattern_match runs once per resolved CALL edge inside
emit_service_edge — that's 7 pattern lists × ~30 patterns × strstr
= ~210 strstrs per call. On kubernetes (~600k resolved CALL edges)
the SAME resolved QN ("fmt.Errorf", "context.Context.Done",
"errors.New", etc) repeats thousands of times across files. Caching
turns the linear pattern-list scan into one hash lookup after the
first miss.

API additions in service_patterns.h:
  void cbm_service_pattern_cache_begin(void);
  void cbm_service_pattern_cache_end(void);

Cache is TLS (no contention) scoped to the resolve_worker's lifetime
in the parallel_resolve phase. Capacity 8192 entries per worker —
covers typical project QN diversity without rehashing.

Encoding: enum value + 1 in the void* slot (0/NULL means "miss").
Two helpers svc_enum_to_ptr / svc_ptr_to_enum hide the trick.

Benchmark on kubernetes (12 workers, 20,650 files):
- parallel_resolve wall: 98,520 -> 90,104 ms (-8.5% vs Chunk 1
  baseline, -12.8% vs original 103,281 baseline)
- resolve_calls CPU:    992,427 -> 885,663 ms (-10.8% vs Chunk 1,
  -5.4% vs original — Chunk 1 added overhead that Chunk 2 recovers
  AND surpasses)
- resolve_usages CPU:    11,977 -> 10,944 ms (-8.6%)
- total_loop CPU:     1,005,409 -> 897,561 ms (-10.7%)
- Tests: 3616/0
- Graph: 297,082 -> 297,080 nodes (-2), 1,153,482 -> 1,156,097 edges
  (within 0.2%, semantic-preserving)
- zod/serilog sanity: edges within +/-3 of baseline
- Peak RSS: 7.46 -> 7.75 GB (+290 MB for 12 × 8192-entry caches)

Cumulative trajectory: original broken state ~35 min K8s ->
1.92 min wall (-49× total). The per-file is_import_reachable cache
(Chunk 1) + the per-worker service-pattern cache (Chunk 2) together
save ~13s of resolve wall time. Next chunks target the gbuf_insert_
edge hot path and remaining strstr scans.
2026-05-21 16:28:55 +02:00
Martin Vogel 109c9f07c1 perf(registry): per-file memoization cache for is_import_reachable
Wraps is_import_reachable's O(candidates × imports × strstr) hot loop
with a TLS hash-table cache scoped to the resolve_worker's per-file
iteration. Same callee_name appears in many call sites and each
lookup re-evaluates the same ~30 candidates against the same imports
— with the cache, first eval pays the strstr cost, repeats are O(1).

API additions in pipeline.h:
  void cbm_registry_reach_cache_begin(int estimated_capacity);
  void cbm_registry_reach_cache_end(void);

resolve_worker calls _begin right after build_import_map and _end
right before the loop's free_import_map (the cache scope must match
the import_vals scope — different files have different imports, so
the cache is invalidated between files).

Benchmark on kubernetes (12 workers, 20,650 files):
- parallel_resolve wall: 103,281 -> 98,520 ms (-4.6%)
- resolve_usages CPU:     33,971 -> 11,977 ms (-65%) — cache hits hard here
- resolve_calls CPU:     935,958 -> 992,427 ms (+6.1%) — cache adds overhead
  here without saving work, because the Tier 3 LSP override path hits
  first and bypasses cbm_registry_resolve for most CALL entries.
  Usage/throws/rw paths always go through cbm_registry_resolve so the
  cache helps them; the net wall improvement comes from that side.
- Tests: 3616/0
- Graph: 297,082 -> 297,115 nodes (+33), 1,153,482 -> 1,155,005 edges
  (within 0.1%, semantic-preserving)
- zod/serilog sanity: nodes exact, edges +/-1 (within tolerance)

The strstr overhead per call wasn't the dominant resolve_calls cost —
the next chunks need to attack the actual hot spots: emit_service_edge's
cbm_service_pattern_match (6 lists × ~30 patterns × strstr per call =
~180 strstrs/call) and the gbuf_insert_edge allocator pressure.
2026-05-21 16:11:41 +02:00
Martin Vogel e81853d105 feat(parallel): per-sub-phase timing in resolve_worker
Instrument resolve_worker with per-sub-phase atomic ns counters
(import_map, cross_lsp, resolve_calls, resolve_usages, resolve_throws,
resolve_rw, resolve_semantic) plus a whole-iteration total_loop and
files_visited count. Logged at end of cbm_parallel_resolve as three
log lines (split to avoid the PP_RING=4 TLS-slot aliasing in itoa_log
that masked the data in the first attempt).

Profiling result on kubernetes (12 workers, 20,650 files):
  parallel_resolve wall: 103,281 ms
  total_loop CPU-ms:     970,352  (78.3% worker utilization, busy)
  phase_sum CPU-ms:      970,303  (99.99% coverage of loop body)

  resolve_calls:    935,958  96.45%  ← THE BOTTLENECK
  resolve_usages:    33,971   3.50%
  resolve_semantic:     192   0.02%
  resolve_calls (cost): 935,958 / 616,795 = 1,517 µs per call edge
  resolve_usages (cost): 33,971 / 942,846 =    36 µs per usage edge

Calls are 42× more expensive per edge than usages. The extra work in
resolve_file_calls (vs resolve_file_usages) traces to:

  1. is_import_reachable (registry.c:154) — O(imports × 2*strstr) per
     candidate × ~30 candidates per name × 1.6M call attempts =
     ~480 sec of strstr work alone.
  2. cbm_service_pattern_match — O(6 lists × ~30 patterns × strstr)
     per call = ~48 sec aggregate.
  3. try_field_type_hint, emit_service_edge dispatch overhead.

These are now the next optimization targets — replace substring
matching with hashed prefix lookups (trie or hash of module
fragments), and add per-file memoization of is_import_reachable
results so the same candidate doesn't get re-evaluated per call site.

Instrumentation overhead is negligible (~30ns atomic_fetch_add per
phase per file = <100µs aggregate on K8s) and gives durable
telemetry for future perf work.
2026-05-21 15:29:19 +02:00
Martin Vogel df6ddf058b perf(parallel): O(1) per-file lsp resolution index + ASan fix
Two changes:

1. resolve_file_calls now builds a per-file CBMHashTable mapping
   'caller_qn|callee_short' -> CBMResolvedCall* once at function
   entry, then does O(1) lookups instead of the O(N) linear scan in
   cbm_pipeline_find_lsp_resolution. Falls back to the linear scan
   only for keys the index misses (preserves all existing semantics).

   The hash key uses callee_qn's short name (last dot-separated
   segment) to match the original linear-scan's match rule. On insert,
   keeps the highest-confidence entry per key.

2. Memory safety: keys must be strdup'd before cbm_ht_set, because
   cbm_ht_set stores the key pointer rather than copying. The first
   draft used stack-allocated 'char key[1024]' inside the loop body —
   ASan caught it as stack-use-after-scope when subsequent cbm_ht_get
   calls dereferenced the dangling stack frame addresses. Fixed:
   strdup on insert, reuse cbm_ht_get_key when updating an existing
   bucket (no double-free), free all keys via cbm_ht_foreach before
   cbm_ht_free on cleanup.

Benchmark on kubernetes (12 workers, 20,650 files):
- parallel_resolve: 113,300ms (Tier 3 meta) -> 110,924ms (this commit)
  ~2.1% improvement — within run-to-run noise. Despite the O(N) -> O(1)
  algorithmic improvement, the (calls × resolved_calls) product on
  K8s Go files is small enough at this scale that the linear scan was
  not the dominant cost. Other call sites of cbm_pipeline_find_lsp_
  resolution (e.g. pass_calls.c) keep the original O(N) path since
  this file-level index is local to resolve_file_calls.

The change is still net positive:
- Removes a real memory-safety bug found by ASan
- O(N) -> O(1) algorithmic improvement at the call site, even if its
  scale impact is modest at K8s size
- Sets up larger codebases (where the linear scan would dominate) to
  benefit without further work

Tests: 3,616 / 0 failed including parallel_python_lsp_override_cross_
file_emits_lsp_strategy_edges (which directly exercises the LSP
resolution path). Graph counts: nodes -1 vs baseline, edges +945 vs
baseline (within 0.1%, semantic-preserving). zod/serilog sanity:
nodes exact, edges +4/+5 (within tolerance).

Real bottleneck for the remaining ~110s on K8s is elsewhere — likely
the cumulative cost across 20K files of registry_resolve + gbuf_find_
by_qn + gbuf_insert_edge per resolved entry across all 5 resolve_file_
passes. Profiling needed to confirm; the lightweight per-worker edge
buffer (deferring dedup to merge) is the next obvious attack vector.
2026-05-21 14:56:34 +02:00
Martin Vogel 96f6891ff2 perf(lsp): Tier 3 metadata-driven cross-LSP — skip AST re-walk for Go
KEY ARCHITECTURAL INSIGHT (credit: user observation):
The per-file LSP during extract ALREADY captures the inferred receiver
type QN + method name for unresolved cross-file calls — emit_unresolved_
call at go_lsp.c:1242-1245 stores callee_qn as '<FullReceiverTypeQN>.<
MethodName>' in result->resolved_calls flagged with strategy='lsp_
unresolved'. The cross-LSP step was REDUNDANTLY re-deriving this same
metadata via a second tree-sitter parse + AST walk.

Cross-LSP for Go is now METADATA-DRIVEN:

  cbm_go_fast_resolve_qualified_calls (rewritten):
    - Iterates result->resolved_calls where strategy=='lsp_unresolved'
    - For each entry: try direct registry lookup of callee_qn;
      if that misses, split prefix.suffix and resolve prefix via the
      file's import map then lookup '<module_qn>.<suffix>' in registry
    - Emits resolved entry on top (cbm_pipeline_find_lsp_resolution
      picks highest confidence)
    - NO tree-sitter parse, NO AST walk, NO scope tracking

  resolve_worker (pass_parallel.c):
    case CBM_LANG_GO now calls ONLY the metadata-driven fast path.
    cbm_run_go_lsp_cross_with_registry (the slow AST-walk version) is
    no longer invoked for Go — it would just re-derive identical
    metadata via a second AST walk and arrive at the same answers.

Benchmark (kubernetes, full fresh index, 12 workers):
- parallel_resolve: 1.89 min (Tier 3 meta) vs 1.86 min (Tier 2) — parity
- lsp_cross.slow lines: 0 (no tree-sitter fallback runs)
- Nodes: 297,081 vs 297,082 baseline (-1)
- Edges: 1,153,163 vs 1,153,482 baseline (-319, within 0.03%)
- Tests: 3,616 / 0 failures
- zod/serilog sanity: nodes exact, edges +1-3 (within 0.03%)

The wall-time parity is informative: the AST re-walk wasn't the
dominant cost. Per-file budget (66ms across 20,650 files / 12 workers)
is now dominated by the 5 sequential resolve passes (resolve_file_
calls/usages/throws/rw/semantic) pushing ~10M total edges into the
per-worker gbuf with arena allocs + hash-bucket inserts. The Tier 3
work eliminates a genuine architectural redundancy (two walks of the
same AST producing the same metadata) and sets up Python/C/etc to
adopt the same metadata-driven pattern in follow-up commits — but
hitting the user-target <30s K8s requires attacking the gbuf hot
path, not the cross-LSP path.
2026-05-21 13:13:16 +02:00
Martin Vogel 9552a7f9bf fix(pipeline): drop duplicate rel declaration in resolve_worker
When the genskip commit (aecd861) moved `const char *rel` earlier in
the resolve_worker loop body (so the is_generated check could use it
before the cross_lsp_eligible computation), the original declaration
further down in the loop body wasn't removed — fails to compile with
"redefinition of 'rel'". This was caught by the post-commit bench
agent and fixed in-place but not landed; now it is.
2026-05-21 11:48:22 +02:00
Martin Vogel aecd86135f perf(lsp): skip cross-LSP for machine-generated files
Investigation of kubernetes' largest Go files revealed the perf tail
is dominated by machine-generated code:

  pkg/generated/openapi/zz_generated.openapi.go      74,266 lines
  staging/src/k8s.io/api/core/v1/generated.pb.go     71,154 lines
  Multiple *.pb.go protobuf files                    12-14K lines each
  209 files in staging/.../client-go/applyconfigurations/

These files are boilerplate (getters/setters/marshal/unmarshal) with
limited semantic value for code-navigation queries. But because tree-
sitter parse of a 70K-line file alone is ~1-2 seconds, even one
unresolved cross-file call in such a file triggers the full cross-LSP
overhead and dominates the wall time.

Skip cross-LSP for files matching common generated-file conventions:
  *.pb.go            (Go protobuf)
  zz_generated*      (k8s codegen convention)
  *_generated.go     (general Go codegen)
  *.gen.go           (general Go codegen)
  /applyconfigurations/  (k8s codegen subtree)
  *_pb2.py, *_pb2_grpc.py  (Python protobuf)
  *.pb.cc, *.pb.h, *.pb-c.{c,h}  (C/C++ protobuf)

The per-file LSP during extract still indexes their defs and calls
normally — only the cross-file resolution refinement is skipped.
Graph quality: zero loss for hand-written code; for generated code
cross-file method resolution falls back to whatever per-file LSP
captured (which is typically complete for intra-package boilerplate).
2026-05-21 11:29:21 +02:00
Martin Vogel 8ce2228834 perf(lsp): smart-prune cross-LSP + add Python Tier 2 wiring
Two changes layered:

1. Smart pruning of cross-LSP work — the cross-LSP work (re-parse +
   AST walk + registry lookups, ~50-150ms per file) only adds value
   if a file has unresolved cross-file method calls. If the per-file
   LSP during extract already resolved every call
   (result->resolved_calls.count >= result->calls.count) or the file
   has zero calls at all, the AST has nothing left for cross-LSP to
   improve. cross_lsp_eligible now additionally requires:
       result->calls.count > 0
       && result->resolved_calls.count < result->calls.count
   On kubernetes this skips 87% of files (17,949 of 20,650) with
   zero semantic loss — graph node/edge counts within ±0.14% of the
   non-pruned baseline.

2. Python Tier 2 wired through pipeline. The cbm_py_build_cross_registry
   and cbm_run_py_lsp_cross_with_registry helpers from the previous
   commit are now actually populated and dispatched. resolve_worker's
   switch now handles CBM_LANG_PYTHON in addition to CBM_LANG_GO.

3. cbm_parallel_resolve's cross_registries param is now typed void*
   in the header (cast back to CBMCrossLspRegistries* in the impl) —
   the typedef CBMCrossLspRegistries lives in pass_lsp_cross.h which
   transitively pulls in lsp/go_lsp.h. Dodging the typedef-vs-struct-
   tag mismatch this way keeps pipeline_internal.h light (no LSP
   header dependency).

Bench on kubernetes (mode=full, fresh DB):
- parallel_resolve: 1.86 min (Tier 2 Go) -> 2.06 min (+10% regression)
  The slight regression on Go-heavy k8s comes from Python registry
  build (lsp_cross_prepare: 911 -> 1,014 ms) and from the fact that
  the 13% of files cross-LSP still runs on are the EXPENSIVE files
  with many unresolved calls — pruning the 87% trivial files saves
  less than the remaining 13% hard files cost.
- Tests: 3,616 / 0 failed. parallel_python_lsp_override_cross_file_
  emits_lsp_strategy_edges still PASS.
- zod/serilog: node/edge counts within ±0.1% of known-good.

The smart-pruning + Python wiring is net-positive across the language
matrix (Python-heavy repos benefit from the new Tier 2 path) but
doesn't move the needle on Go-heavy k8s. The remaining ~2 min on k8s
is dominated by the AST re-walk inside cross-LSP — eliminating that
requires capturing unresolved-call metadata during extract so cross-
LSP is pure registry lookup (no re-parse). Follow-up commit.
2026-05-21 11:24:44 +02:00
Martin Vogel 23d404a056 perf(lsp): Tier 2 — pre-built per-language cross-LSP registries (Go + Python)
The gopls 'package summary' pattern, applied properly: build the cross-
LSP CBMTypeRegistry ONCE per language at pipeline time (in a dedicated
cross_lsp_arena), share it READ-ONLY across all resolve workers via the
CBMCrossLspRegistries struct. Per-file work then becomes just tree-
sitter parse + AST walk + O(1) hash lookups — zero per-file registry
rebuilds for languages with a pre-built registry.

Each LSP gains two new public functions:

  CBMTypeRegistry *cbm_<lang>_build_cross_registry(arena, defs, def_count)
    — filters all_defs to its language, init + stdlib + registration
      loop + cbm_registry_finalize, returns finalized registry.

  void cbm_run_<lang>_lsp_cross_with_registry(arena, source, ...,
                                              CBMTypeRegistry *reg, ...)
    — skips the per-file build AND the per-file Phase 1b/1c AST scans
      (those mutate the registry, which would race on a shared read-
      only reg). Trade-off: a few file-local type aliases discovered
      only by Phase 1b are missed; in practice the per-file extract
      LSP already captured them via embedded_types/field_defs/etc.
      Tested with parallel_python_lsp_override_cross_file_emits_lsp_
      strategy_edges — fused + filtered + prebuilt path stays correct.

CBMLSPDef gains a CBMLanguage lang field set in pxc_build_lsp_def so
the per-language build functions can filter the global all_defs[].

cbm_parallel_resolve signature now takes CBMCrossLspRegistries* instead
of a single CBMTypeRegistry*. The resolve_worker dispatches:
cbm_pxc_registry_for_lang(lang) returns the right pre-built reg if any;
the worker then switch()es to the matching _with_registry variant.
Languages without a _with_registry variant fall through to the existing
filter + per-file build path.

Kubernetes (Go-heavy, 12k files, 139k defs) measured perf:
  parallel_resolve: 5.51 min (strdup-removal baseline) -> 1.86 min
                    = 2.96x speedup
  parallel.resolve.lsp_cross.slow lines: 1,258 -> 2
                    (-99.8%; the 2 remaining are Python files, not Go)
  Peak RSS: 6.29 GB -> 6.23 GB (slight decrease — pre-built registry
            shared, no per-file scratch)
  All 3,616 tests pass; zod/serilog graphs exact match (per-file path
  unaffected).

Cumulative trajectory from the original broken state on kubernetes:
  35 min -> 19 min (finalize) -> 6.24 min (module-def filter) ->
  5.51 min (strdup-removal) -> 1.86 min (Tier 2 Go) = 18.8x total.

Languages wired so far: Go, Python. C/C++, TS/JS, PHP, C# still use the
per-file filter+build path until their _with_registry variants land in
follow-up commits.
2026-05-21 10:39:48 +02:00
Martin Vogel 3b373b725f chore(deps): vendor wyhash 4.3 (public domain, single header)
wyhash is a non-cryptographic hash function ~2x faster than FNV1A
for the long string keys used in CBMTypeRegistry's hash buckets
(qualified names average ~50 bytes). The header is unused for now
— prepared for a future Tier 3 symbol-interning pass that replaces
the QN-string-keyed hashtables with u32-keyed ones (rustc pattern)
where wyhash's lower collision rate matters more.

Source: https://github.com/wangyi-fudan/wyhash (Unlicense)
Vendored as a single header at internal/cbm/vendored/wyhash/wyhash.h.
2026-05-21 10:16:06 +02:00
Martin Vogel 770d2731cc perf(lsp): finalize cross-LSP registry + borrow def strings
Two perf changes across the 6 cross-LSP resolvers:

1. cbm_registry_finalize after the per-file registry build (all 6
   LSPs). Builds the FNV1A hash buckets so lookups go through the
   O(1) hashed path in type_registry.c:154. Previously this was
   NEVER called anywhere in the codebase, so every method/type
   lookup fell through to the O(N) linear scan — the root cause of
   the 35-min kubernetes regression. Lookup count on k8s: ~50 calls
   * 110k defs * 11k files = ~64B strcmps under the old fallback.

2. In go/c/py/php registration loops, replace cbm_arena_strdup(arena,
   d->X) with d->X directly. The strings are borrowed from the
   caller's all_defs[] arena which outlives the cbm_run_X_lsp_cross
   call by the whole pipeline lifetime. Saves 5 wasted strdups per
   def per file = ~6B mallocs avoided on kubernetes.

TS and CS use a different registration architecture (no inline
strdups in the body — uses a per-file ctx). Only the finalize change
applies there.

Kubernetes wall-time vs prior commit:
- After finalize alone: 35 min -> 19 min (~1.8x)
- After module-def filter (prior commit): -> 6.24 min (~5.6x)
- After strdup-removal (this commit): -> 5.51 min (~6.4x)
- Memory: 7.14 GB -> 6.29 GB peak RSS (-12%)
All 3616 tests pass, zod/serilog graph output exact match.
2026-05-21 10:15:48 +02:00
Martin Vogel 75a0df688f feat(pipeline): fuse cross-LSP into resolve worker + module-def filter
Previously cbm_pipeline_pass_lsp_cross ran as a separate sequential
pass that re-read every source file from disk and re-parsed every
tree-sitter tree on a single thread — a 50x regression on the
kubernetes Go monorepo (extract 10s, lsp_cross 9+ min when killed).

Three changes:

1. CBMFileResult gains const char *source / int source_len. The
   parallel extract worker copies source bytes into result->arena
   (per-file 100MB cap, project-wide 2GB atomic cap) so the fused
   cross-LSP step can run without disk I/O. Source pointer is
   borrowed by downstream readers; arena owns the storage.

2. Cross-LSP work moves into the parallel resolve worker. Each worker
   processes its file with the existing parallel infrastructure
   (worker pool + work stealing). Slab is reclaimed after each
   per-file cross-LSP call to bound peak slab memory. The previously
   separate lsp_cross pass entry function is kept for the sequential
   pipeline path; the parallel path no longer invokes it.

3. CBMModuleDefIndex (gopls 'package summary' pattern) — an inverted
   index module_qn -> list of def indices built once in O(D) before
   resolve_worker fires. Per file, cbm_pxc_filter_defs_for_file slices
   all_defs down to the file's own_module + imported modules only,
   typically 50-100x smaller than the global ~110k defs on kubernetes.
   Drops the per-file registry build from O(all_defs) to O(relevant).

Other plumbing: cbm_parallel_resolve signature gains all_defs,
def_count, def_modules, module_def_index params. pipeline.c builds
the index between registry_build and parallel_resolve and frees it
after. pipeline_incremental and tests/test_parallel updated to match.

Helpers in pass_lsp_cross.c exposed via the header: cbm_pxc_has_cross_lsp,
cbm_pxc_collect_all_defs, cbm_pxc_run_one(_ts), cbm_pxc_ts_modes,
cbm_pxc_build_module_def_index, cbm_pxc_filter_defs_for_file.

Kubernetes wall-time: 35 min -> 6.24 min (~5.6x) before the followup
perf commits in this series.
2026-05-21 10:15:29 +02:00
Martin Vogel 6226972f13 fix(lsp): guard trailing append in TS type-text union/intersection parser
parse_ts_type_text in ts_lsp.c builds a 16-slot CBMType* members[] for
union (|) and intersection (&) types. The in-loop branch correctly
caps additions at mc<15, but the trailing post-loop append that
collects the final member after the last separator was unconditional,
and the subsequent members[mc] = NULL sentinel could then write to
members[16] — one past the array end.

Caught by UBSan on the zod codebase (a TS union with >=16 members
exists). Non-deterministic crash signature under pthread-parallel
extraction (12 workers): exit 134 / SIGABRT with no stack, because
libc's allocator detected the heap corruption and called abort().
Single-fixture-file test suite never exercised the parallel path or
a wide-enough union, so existing ASan/UBSan coverage missed it.

Fix: mirror the in-loop guard on the trailing append (cap at mc<15).
Add regression test tslsp_union_many_members_no_overflow that
constructs a 20-member string-literal union — extracting must not
crash. UBSan: clean. Full suite: 3616 passed, 0 failed.
2026-05-20 00:50:57 +02:00
Martin Vogel d12e88919d test(cli): align hook tests with non-blocking matcher (Grep|Glob)
The cli_upsert_claude_hook_fresh / _existing tests still asserted the
old matcher 'Grep|Glob|Read', missed by the previous hook-augment
commit. Update them to mirror what scripts/smoke-test.sh now enforces:
exact matcher value '"Grep|Glob"' present AND 'Glob|Read' absent.
The second assertion regress-protects against re-introducing Read
into the matcher (issue #362).
2026-05-19 23:57:00 +02:00
Martin Vogel c29e6d51f4 fix(hooks): replace blocking Claude PreToolUse gate with non-blocking augmenter
The previous PreToolUse hook gated Grep/Glob/Read/Search with 'exit 2'
on the first call per session, which broke Claude Code's
read-before-edit invariant (issue #362) and could deny tool calls under
upgrade/missing-binary failure modes.

Replace it with a structurally non-blocking augmenter:

- New 'codebase-memory-mcp hook-augment' subcommand reads the hook JSON
  from stdin and, for Grep/Glob, queries search_graph (in-process, no
  shell) and emits hookSpecificOutput.additionalContext. Every failure
  path (no project, short token, missing binary, slow query, timeout)
  exits 0 with no stdout — the hook physically cannot block a tool call.
- 300 ms SIGALRM/_exit(0) in-process deadline; 5 s settings.json timeout
  backstop. Output is written exactly once at the very end, so a
  mid-work timeout yields a clean no-op (never partial JSON).
- Matcher narrowed to 'Grep|Glob' (Read explicitly excluded) for Claude;
  Gemini matcher narrowed to 'google_search|grep_search' (excludes
  read_file) for the same reason.
- The installed shim is a thin wrapper that delegates to the binary;
  legacy filename 'cbm-code-discovery-gate' is kept so existing
  settings.json entries upgrade with zero migration. Installer refuses
  to embed binary paths containing a double quote (shim injection
  defense).
- Per-agent 'old matchers' lists let upsert/remove clean up historical
  matcher strings during upgrade.
- Smoke tests (8d/8e/8l) updated to assert the new behavior and
  regress-test against re-introducing Read in the matcher or 'exit 2'
  in the shim.
- Session reminder text updated: 'always Read a file before editing it'
  replaces the prior 'fall back to Read only for text content'.

(cherry picked from commit f72c8e68c4d91e52911a569a967ad782ce5472b2)
2026-05-19 23:46:46 +02:00
Martin Vogel 32bb56ef3f fix(mcp): force grep filename prefix in scoped search_code
build_grep_cmd's POSIX scoped path uses xargs to pass indexed files
to grep. When xargs supplies a single file, BSD/GNU grep omit the
filename from output by default (only added with multiple files),
yielding 'linenum:content' instead of the 'path:linenum:content'
that collect_grep_matches requires — matches were silently discarded
and search_code returned zero results.

Add -H so grep always prefixes the filename regardless of file count.
Harmless for the common multi-file case (output is unchanged), fixes
the single-indexed-file edge case exposed by the search_code_multi_word
test fixture.
2026-05-19 23:43:42 +02:00
Martin Vogel 03e01dc616 feat(lsp): add C# / .NET hybrid LSP resolver
Add a C# language LSP resolver mirroring the existing Go/C/PHP/Python
modules: type evaluation, using-directive resolution, method lookup,
and a corpus-seeded .NET BCL stdlib data table.

- internal/cbm/lsp/cs_lsp.{c,h}: resolver implementation
- internal/cbm/lsp/generated/cs_stdlib_data.c: BCL type/method tables
- wire CBM_LANG_CSHARP dispatch in cbm.c and lsp_all.c
- register cs_lsp + cs_lsp_bench suites in the test harness
- tests/test_cs_lsp.c, tests/test_cs_lsp_bench.c

(cherry picked from commit 8f677c67a0c9d7829cd6ac27393de2d0aaaac534)
2026-05-19 22:47:53 +02:00
edwardmhughes 22153563cd fix(store): use PASSIVE checkpoint to avoid file-shrink under concurrent readers
cbm_store_checkpoint() invoked SQLITE_CHECKPOINT_TRUNCATE, the most
aggressive mode. When two cbm-mcp processes share a cache dir, one
process's TRUNCATE can shrink files while another has them mmap'd,
raising SIGBUS on macOS. PASSIVE never blocks readers and never
ftruncate()s either file; SQLite still autocheckpoints in PASSIVE
mode at 1000-page boundaries, so reclamation is unaffected for
single-process users.

Recommended by SQLite docs for shared databases:
https://www.sqlite.org/pragma.html#pragma_wal_checkpoint
2026-05-10 21:10:27 +02:00
edwardmhughes 093707cda2 feat(store): expose mmap_size via CBM_SQLITE_MMAP_SIZE env
Hard-coded `PRAGMA mmap_size = 67108864` in configure_pragmas() left
no path for users running multiple cbm-mcp instances against the
same cache to opt out of memory-mapped I/O. On macOS, when one
instance's checkpoint or reindex truncates the DB file under
another instance's live mmap, accessing the now-missing pages
raises SIGBUS, taking the process down.

Setting CBM_SQLITE_MMAP_SIZE=0 reverts to read()/pread() I/O,
which returns recoverable SQLITE_IOERR instead of crashing the
process.

- Default unchanged (67108864 / 64 MB). No behavior change for
  single-instance users.
- Malformed values (non-numeric, partial-numeric) fall back to
  the default rather than failing the store open.
- Negative values clamp to 0.
- New tests: tests/test_store_pragmas.c covers all five resolver
  paths plus an integration smoke that opens a file-backed store
  with mmap disabled.

Empirical evidence: 9 SIGBUS crash reports collected on macOS
arm64 v0.6.0 in a 14-hour window, all signature 'cluster_pagein
past EOF' with stacks bottoming in SQLite btree code under the
watcher thread's incremental-index pipeline.
2026-05-10 21:03:42 +02:00
Christof Meerwald a338ff3626 Add support for NetBSD, FreeBSD, and OpenBSD. 2026-05-10 20:58:53 +02:00
George d83553a1ba fix(store.c): improve root path integrity check with range-based validation
Replace the fragile NOT IN list of individual characters with a range-based BETWEEN 'A' AND 'Z' check for validating project root paths.
The previous query only excluded letters A–H, leaving paths starting with I–Z silently invalid.
In Windows, all the alphabet can be used.
A Windows drive starting with letters I-Z breaks the saving of the database.
2026-05-10 20:49:03 +02:00
Martin Vogel eca433bd58 fix(security): block " < > in cbm_validate_shell_arg
The Windows search path landed in 82a9052 wraps shell args in cmd.exe-
level "powershell -Command \"...'%s'...\"".  PowerShell's single quotes
hold the inner '%s' interpolation, but a literal " inside the user-
supplied value can close the cmd.exe outer quote.  With ' ; | & $ `
already blocked, that wouldn't reach RCE on its own — but < and > were
unblocked, so a quote-break followed by cmd.exe redirection (e.g.
*">C:\evil.txt") would expose a file-write primitive.

Block " < > unconditionally.  The validator's contract is "safe inside
single quotes for shell interpolation"; on POSIX these aren't strictly
necessary (single quotes hold), but on Windows the cmd.exe→powershell
wrapping makes them load-bearing, and unconditional blocking keeps the
validator simple — no ifdef-laddered policy and no surprises if a future
caller invokes it from a different shell context.

Three tests added to tests/test_security.c covering each new char.
2026-05-10 20:43:45 +02:00
noctrex 82a905284c feat(mcp): add Windows support for code search via PowerShell 2026-05-10 20:41:10 +02:00
KuaaMU 1a4973d01c fix: add .m extension to EXT_TABLE for content-based disambiguation
The .m extension was missing from EXT_TABLE, causing detect_file_language
to return CBM_LANG_COUNT (Unknown) before the content-based disambiguation
logic (cbm_disambiguate_m) could ever run. This made .m files invisible
to the indexer.

Add {".m", CBM_LANG_MATLAB} to EXT_TABLE so the initial lookup succeeds,
allowing the subsequent cbm_disambiguate_m call to correctly determine
whether the file is MATLAB, Objective-C, or Magma.

Fixes #296
2026-05-10 20:32:52 +02:00
Jan Deelstra cc7ef348df fix(mcp): search_code converts multi-word patterns to regex
When pattern contains spaces and is not already a regex, convert to a
regex with '.*' between words: "foo bar baz" → "foo.*bar.*baz".
This matches all terms in order on a line instead of requiring the
exact multi-word substring.

Metacharacters in user input are escaped so the conversion is safe
for arbitrary text.

Includes test: search_code_multi_word verifies that a two-word query
matches a line containing both words non-contiguously.
2026-05-10 20:25:43 +02:00
Martin Vogel 2b6f80ec3c fix(sqlite_writer): log skipped oversized index cells
98d5cac silently dropped index cells that exceed a single SQLite page,
which is the correct semantic (the index B-tree has no overflow page
mechanism, unlike the record B-tree fixed in #175). But silent drops
make the failure mode invisible — a caller hitting deeply nested
namespaces or very long file paths gets a partial index with no
indication anything was skipped.

Add an fprintf(stderr) at the skip site reporting the cell length and
its index, matching the existing logging style in this file (lines 311,
1305) so the dropped cells are observable for diagnosis. Consistent
with the cbm_write_db: prefix used by surrounding warnings.
2026-05-10 19:59:08 +02:00
Jos Joosten 98d5cacd76 fix(sqlite_writer): skip index cells that exceed a single page
write_index_btree() flushed the page buffer whenever the next cell
didn't fit, on the assumption that a freshly-initialised page would
always accept any cell. That invariant fails for index cells whose
payload (qualified_name, file_path, etc.) is larger than a full
SQLite page — after the flush, pb_cell_fits() is still false but the
code calls pb_add_cell() anyway. The subsequent content_offset
underflow corrupts the page header and the next write triggers
SIGBUS on large repos (observed on a Ruby codebase with ~215K nodes).

Re-check pb_cell_fits() after the flush and continue past cells
that still don't fit. Index entries whose keys exceed a full page
can't be stored and are not expected to survive the writer anyway —
the rest of the index is correctly preserved and the indexer no
longer crashes. Record overflow pages (#175) already covers the
record-btree side of this same shape of bug.
2026-05-10 19:57:28 +02:00