diff --git a/.clangd b/.clangd new file mode 100644 index 000000000..769242d25 --- /dev/null +++ b/.clangd @@ -0,0 +1,36 @@ +# clangd configuration for codebase-memory-mcp +# +# Mirrors the include paths and defines from Makefile.cbm CFLAGS_COMMON so +# clangd can resolve all headers without needing compile_commands.json. +# Paths are relative to the project root (where this file lives). +# +# Works with both clang (macOS/Linux) and gcc — clangd uses these flags +# directly regardless of which compiler is selected for the build. + +CompileFlags: + Add: + - -std=c11 + - -D_DEFAULT_SOURCE + # Project source headers + - -Isrc + # Vendored libraries: yyjson, xxhash, sqlite3 wrappers + - -Ivendored + - -Ivendored/sqlite3 + - -Ivendored/mimalloc/include + # Internal cbm extraction layer and tree-sitter runtime + - -Iinternal/cbm + - -Iinternal/cbm/vendored/ts_runtime/include + # Remove flags clangd cannot handle (sanitizer, link flags) + Remove: + - -fsanitize=* + - -fno-omit-frame-pointer + - -lstdc++ + - -lpthread + - -lm + - -lz + +Diagnostics: + # Suppress false-positive "implicit declaration" warnings caused by + # clangd analysing files in isolation without the full TU context. + Suppress: + - pp_file_not_found diff --git a/.gitignore b/.gitignore index 05e7b1bfe..8e6d89fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,10 @@ bin/ # Test artifacts *.test *.out +*.log coverage.txt +# Clang static analyzer (make -f Makefile.cbm test-analyze) per-file reports +*.plist # Test fixture temp dirs (created by C test suite in CWD instead of /tmp/) cbm_*/ @@ -26,6 +29,16 @@ cli-*/ .DS_Store Thumbs.db +# Git worktrees (created by Claude Code subagents) +.worktrees/ + +# Runtime/session artifacts +session_project +project +project|params.project +conductor/ +with + # Database files (local cache) *.db *.db-wal @@ -40,9 +53,11 @@ Thumbs.db # Local project memory (Claude Code auto-memory) memory/ reference/ +.claude/ # Local-only scratch / session notes (never pushed) private/ +notes/ # Build artifacts build/ @@ -51,11 +66,23 @@ graph-ui/dist/ # Generated reports BENCHMARK_REPORT.md +benchmark-results/ +*.facts/ TEST_PLAN.md +scripts/autotune_results.json CHANGELOG.md -# Soak test output +# Local memory/soak outputs (uploaded as CI artifacts, never committed) +memlab-*.jsonl soak-results/ +soak-results-query-leak/ +# ...and ANY ad-hoc run directory at the repo root. The two exact names above did +# not match hand-named runs, so 13 soak directories and 4 memlab files (78 files +# of logs and CSVs) were committed to main by accident in 7808eee. Root-anchored +# so nothing under scripts/ or tests/ is affected — scripts/soak-legs.sh and +# scripts/soak-test.sh stay tracked. +/soak*/ +/memlab-* # LSP originality-check reference cache (scripts/check-lsp-originality.sh) .lsp-refs/ @@ -66,14 +93,3 @@ graph-ui/.npm-cache-local/ # Python bytecode from tests/windows/ harness __pycache__/ *.pyc - -# Local soak-leg outputs (uploaded as CI artifacts, never committed) -soak-results/ -soak-results-query-leak/ -# ...and ANY ad-hoc run directory at the repo root. The two exact names above did -# not match hand-named runs, so 13 soak directories and 4 memlab files (78 files -# of logs and CSVs) were committed to main by accident in 7808eee. Root-anchored -# so nothing under scripts/ or tests/ is affected — scripts/soak-legs.sh and -# scripts/soak-test.sh stay tracked. -/soak*/ -/memlab-* diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..82532a474 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "codebase-memory-mcp": { + "command": "sh", + "args": ["-c", "exec $HOME/.local/bin/codebase-memory-mcp"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..65d99d51d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# codebase-memory-mcp — Developer Notes for Claude + +Before changing capabilities or architecture, map the existing design first: look for +equivalent tools, config, helpers, metadata, algorithms, and conventions. Prefer extending +the established path over adding a parallel one. New abstractions should close a named gap +and fit the repo's ownership, allocation, threading, logging, portability, protocol I/O, +and naming patterns. + +Adding a new node/edge JSON property key in `src/pipeline/*.c` or `src/git/*.c`? Add it to +the declared registry in `src/store/store.c`/`store.h` (`schema_declared_node_property_keys`/ +`schema_declared_edge_property_keys`) or `tests/test_schema_declared_property_keys.c` will fail. + +## Build & Test (C server) + +All C targets use `Makefile.cbm`: + +```bash +make -f Makefile.cbm test # build + run full test suite (ASan/UBSan) +make -f Makefile.cbm test-leak # heap leak check (see below) +make -f Makefile.cbm test-analyze # Clang static analyzer (requires clang, not gcc) +``` + +## Memory Leak Testing + +**macOS** — uses Apple's `leaks --atExit` on a separate ASan-free binary: +```bash +make -f Makefile.cbm test-leak +# Report saved to build/c/leak-report.txt +# Target line: "Process NNNNN: 0 leaks for 0 total leaked bytes." +``` + +**Linux** — uses LSan via ASan env var on the regular test runner: +```bash +make -f Makefile.cbm test-leak +# Report saved to build/c/leak-report.txt +# Exit 0 = no leaks. +``` + +Why a separate binary on macOS: `leaks` cannot inspect processes that use a custom malloc (ASan replaces it). The `test-runner-nosan` target rebuilds without `-fsanitize` flags specifically for this purpose. + +## Memory-Corruption Debugging (macOS) + +For non-deterministic corruption (uninit reads, use-after-free, overruns) that ASan/TSan miss — used to investigate the custom-writer B1 bug. Both run the nosan binary (ASan replaces malloc, which defeats these libmalloc knobs); set `CBM_ONLY_SUITE=` to target a slow suite. + +```bash +make -f Makefile.cbm test-memory # MallocScribble=1 + MallocPreScribble=1 + # uninit reads -> 0xAA, use-after-free -> 0x55 (deterministic) +make -f Makefile.cbm test-gmalloc # Guard Malloc (libgmalloc): guard page per allocation + # allocation-owning suites; exact overrun/UAF stack +# Report saved to build/c/mem-report.txt +``` + +`test-memory` is the macOS MSan-equivalent for uninit reads (scribble makes them deterministic). `test-gmalloc` is the strictest — it crashes at the exact bad write, pinpointing the line. Its default suite list stays on allocation-owning in-process surfaces because macOS propagates `DYLD_INSERT_LIBRARIES` into external helpers such as `git`; use `CBM_ONLY_SUITE`/`CBM_ONLY_TEST` for a narrower probe. (No valgrind/MSan on macOS; on Linux use `-fsanitize=memory`.) + +## Project Structure (C server) + +Sources live under `src/`; tests under `tests/`; vendored C libs under `vendored/`. +Tree-sitter extraction and its vendored language grammars live under `internal/cbm/`. +See `CONTRIBUTING.md` for the complete source layout and contribution workflow. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96ce8f861..e34f4e8f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,19 +26,101 @@ The binary is output to `build/c/codebase-memory-mcp`. scripts/test.sh ``` -This builds with ASan + UBSan and runs the full C test suite. Key test files: +This is the local maintainer gate used by the project scripts. It cleans the C build, runs the +full ASan + UBSan C test suite, builds the production binary, and then runs the parent-watchdog +and security-string regression scripts. The exact test count changes as suites are added; use the +runner summary as the source of truth. Key test files: - `tests/test_pipeline.c` — pipeline integration tests - `tests/test_httplink.c` — HTTP route extraction and linking - `tests/test_mcp.c` — MCP protocol and tool handler tests - `tests/test_store_*.c` — SQLite graph store tests +Useful script options and environment: + +```bash +scripts/test.sh --arch arm64 # macOS: force target architecture +scripts/test.sh CC=clang CXX=clang++ # override compiler +CBM_RUN_HANG_TEST=1 scripts/test.sh # include the slower C++ index-hang guard +``` + +## Run C Server Tests + +The MCP server core is written in C and has its own test suite under `tests/`: + +```bash +make -f Makefile.cbm test # full suite with ASan + UBSan +make -f Makefile.cbm test-tsan # thread-sensitive suites with ThreadSanitizer +make -f Makefile.cbm test-leak # heap leak check (see below) +make -f Makefile.cbm test-memory # macOS MallocScribble/PreScribble nosan run +make -f Makefile.cbm test-gmalloc # macOS Guard Malloc nosan run +make -f Makefile.cbm test-analyze # Clang static analyzer (requires clang, not gcc) +``` + +Focused runs are opt-in. Leave these unset for the complete suite: + +```bash +CBM_ONLY_SUITE=pipeline make -f Makefile.cbm test +CBM_ONLY_SUITE=pipeline CBM_ONLY_TEST=exact build/c/test-runner +``` + +By default, tests isolate `CBM_CACHE_DIR` in a temporary directory so local indexes are not +polluted. Set `CBM_TEST_NO_ISOLATE=1` only when intentionally testing the user's configured cache. + +### Build profiles + +Use `scripts/build.sh` for a clean local release build. It is the same entry point used by the +release workflow and produces the default installable binary with `-O2`. For a faster incremental +release rebuild, use `make -f Makefile.cbm cbm`; `make -f Makefile.cbm install` installs that same +optimized artifact. + +Use `scripts/test.sh` for normal development validation. Its C test binary uses `-g -O1` with +ASan and UBSan, then it builds the production binary for the parent/worker watchdog checks. Use +the dedicated `test-tsan`, `test-leak`, `test-memory`, and `test-gmalloc` targets above when +diagnosing concurrency or allocator lifetime behavior. These diagnostic binaries are not valid +performance-benchmark inputs. + +For performance changes, use the canonical fact tables and comparison rules in +[`docs/BENCHMARK_EXPERIMENTS.md`](docs/BENCHMARK_EXPERIMENTS.md). Field, step, +concurrency, and lifecycle meanings come from the generated +[`docs/BENCHMARK_TERMINOLOGY.md`](docs/BENCHMARK_TERMINOLOGY.md), whose source of +truth is `benchmarks/terminology.json`. + +Additional build flags follow the Makefile conventions: + +```bash +make -f Makefile.cbm test SANITIZE= # disable ASan/UBSan, mainly for unsupported toolchains +make -f Makefile.cbm cbm CFLAGS_EXTRA=-DCBM_VERSION=dev +make -f Makefile.cbm cbm STATIC=1 # static link where supported +``` + +**Memory leak detection:** + +On **macOS**, `test-leak` builds a sanitizer-free binary (`test-runner-nosan`) and runs Apple's +`leaks --atExit` on allocation-owning store, pipeline, ranker, and parallel-worker +suites. ASan replaces malloc, so the standard `test-runner` cannot be inspected by `leaks` — the +separate nosan build is required. Deliberate crash tests are excluded because Apple's debugger +stops their child processes before the parent can reap them; this includes the subprocess, +stack-overflow, MCP crash-quarantine, and HTTP socket-inheritance suites. + +On **Linux**, `test-leak` runs the regular `test-runner` with `ASAN_OPTIONS=detect_leaks=1` to +activate LSan. + +In both cases the full report is written to `build/c/leak-report.txt`. A clean run ends with: +``` +Process NNNNN: 0 leaks for 0 total leaked bytes. +``` + ## Run Linter ```bash scripts/lint.sh +make -f Makefile.cbm lint-source-safety ``` -Runs clang-tidy, cppcheck, and clang-format. All must pass before committing (also enforced by pre-commit hook). +Runs clang-tidy, cppcheck, and clang-format. `lint-source-safety` runs the source guard and its +self-tests; it blocks new MCP stdout writes, insecure string APIs, raw env/filesystem calls in +reviewed paths, and other regressions that should use existing CBM helpers instead. All must pass +before committing (also enforced by pre-commit hook). ## Run Security Audit @@ -55,7 +137,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 and tools (JSON-RPC 2.0 over stdio) pipeline/ Multi-pass indexing pipeline pass_*.c Individual pipeline passes (definitions, calls, usages, etc.) httplink.c HTTP route extraction (Go/Express/Laravel/Ktor/Python) diff --git a/Makefile.cbm b/Makefile.cbm index 83bd1feba..266990f60 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -2,10 +2,23 @@ # # Usage: # make -f Makefile.cbm test # Build + run all tests (ASan + UBSan) +# CBM_ONLY_SUITE=pipeline make -f Makefile.cbm test +# # Run one suite after building +# CBM_ONLY_SUITE=pipeline CBM_ONLY_TEST=exact build/c/test-runner +# # Run matching tests inside one suite # make -f Makefile.cbm test-foundation # Foundation tests only (fast) # make -f Makefile.cbm test-tsan # Thread sanitizer build -# make -f Makefile.cbm cbm # Production binary +# make -f Makefile.cbm test-syntax TEST_SYNTAX_SRCS="tests/test_mcp.c" +# # syntax-check selected C units with real test flags +# make -f Makefile.cbm cbm # Production binary (auto-signed on macOS) +# make -f Makefile.cbm install # Build + install to INSTALL_DIR (default ~/.local/bin) # make -f Makefile.cbm clean-c # Remove build artifacts +# +# macOS signing note: +# macOS 25+ enforces ad-hoc code signatures on binaries. Copying a binary +# without re-signing causes immediate SIGKILL at runtime. This Makefile +# runs `codesign --force --sign -` automatically after every build and +# install step on macOS. On Linux and other platforms the step is a no-op. # Compiler selection — override via: make CC=gcc CXX=g++ # macOS: cc (Apple Clang) — universal binary with ASan support @@ -34,6 +47,44 @@ TS_INCLUDE = $(CBM_DIR)/vendored/ts_runtime/include # This ensures we use our vendored copies instead of requiring system libicu-dev. TS_SRC = $(CBM_DIR)/vendored/ts_runtime/src +# ── Optional libgit2 (faster git history parsing) ──────────────── +# Auto-detected via pkg-config. Falls back to popen("git log ...") if absent. +LIBGIT2_CFLAGS := $(shell pkg-config --cflags libgit2 2>/dev/null) +LIBGIT2_LIBS := $(shell pkg-config --libs libgit2 2>/dev/null) +ifneq ($(LIBGIT2_LIBS),) +LIBGIT2_FLAGS = -DHAVE_LIBGIT2 $(LIBGIT2_CFLAGS) +else +LIBGIT2_FLAGS = +LIBGIT2_LIBS = +endif + +# ── Platform detection & code signing ─────────────────────────── +# macOS 25+ kills unsigned or invalidly-signed binaries with SIGKILL. +# codesign --force --sign - applies an ad-hoc signature (no Apple Developer +# account required). On Linux/other platforms this entire block is a no-op. +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +CODESIGN_BIN := $(shell command -v codesign 2>/dev/null) +ifneq ($(CODESIGN_BIN),) +# codesign is available — sign and report +define codesign_binary + @$(CODESIGN_BIN) --force --sign - $(1) 2>&1 && \ + echo " ✓ signed $(1) (ad-hoc, macOS 25+ compatible)" || \ + { echo " ✗ WARNING: codesign failed for $(1) — binary may crash on macOS 25+"; true; } +endef +else +# codesign not found — warn but don't fail the build +define codesign_binary + @echo " ✗ WARNING: codesign not found — $(1) may crash on macOS 25+ (install Xcode CLT)" +endef +endif +else +# Non-macOS: signing is a documented no-op +define codesign_binary + @echo " (signing skipped — not macOS)" +endef +endif + # GCC-only warning suppressions (Clang rejects unknown -Wno-* with -Werror). # Detect GCC by checking for __GNUC__ without __clang__ — handles all versions. IS_GCC := $(shell echo | $(CC) -dM -E - 2>/dev/null | grep -q '__GNUC__' && ! echo | $(CC) -dM -E - 2>/dev/null | grep -q '__clang__' && echo yes || echo no) @@ -50,6 +101,7 @@ endif CFLAGS_COMMON = -std=c11 -D_DEFAULT_SOURCE -D_GNU_SOURCE -Wall -Wextra -Werror \ -Wno-unused-parameter -Wno-sign-compare -Wdate-time \ $(GCC_ONLY_FLAGS) \ + $(LIBGIT2_FLAGS) \ -Isrc -Ivendored -Ivendored/sqlite3 \ -Ivendored/mimalloc/include \ -I$(CBM_DIR) -I$(TS_INCLUDE) @@ -58,32 +110,31 @@ CXXFLAGS_COMMON = -std=c++14 -Wall -Wextra -Werror \ -Wno-unused-parameter \ -I$(CBM_DIR) -I$(TS_INCLUDE) -# Test seams are OPT-IN, never opt-out. Some suites drive behaviour that only a -# test should be able to ask for (fork an orphan the watchdog must reap, publish -# a lease-ownership marker). That code has no production caller and reads exactly -# like malware to a generic classifier, so it must not be in a shipped binary. -# Opt-IN means the failure mode of forgetting the flag is a CLEAN binary rather -# than a leaky one — the opposite choice would make every future release depend -# on someone remembering. scripts/test.sh passes TEST_SEAMS=1 for the suites that -# need it; the test-runner always has them. +# Test seams are opt-in for production binaries. The test runner always enables +# them through EDITOR_TEST_DEFINES, while selected production watchdog tests set +# TEST_SEAMS=1 explicitly. A forgotten flag therefore produces a clean release +# binary instead of silently shipping test-only process and lease controls. TEST_SEAM_DEFINE := ifeq ($(TEST_SEAMS),1) TEST_SEAM_DEFINE := -DCBM_ENABLE_TEST_SEAMS=1 endif -# Production flags (CFLAGS_EXTRA allows CI to inject -DCBM_VERSION) +# Production flags. CFLAGS_EXTRA carries the generated version define; +# EXTRA_{C,CXX,LD}FLAGS are explicit developer/CI overrides appended last. # CBM_BIND_TS_ALLOCATOR=1: bind the tree-sitter runtime to mimalloc (#424). Only # the prod build uses mimalloc (MI_OVERRIDE=1); the test build is CRT+ASan, where # binding would create an alloc/free mismatch, so the guard is prod-only. -CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) $(CFLAGS_EXTRA) -CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 +CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) $(CFLAGS_EXTRA) $(EXTRA_CFLAGS) +CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(EXTRA_CXXFLAGS) # Test flags: debug + sanitizers (override SANITIZE= to disable on Windows) SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer EDITOR_TEST_DEFINES = -DCBM_JSON_LIKE_ENABLE_TEST_API=1 \ -DCBM_TOML_EDIT_ENABLE_TEST_API=1 -DCBM_YAML_ENABLE_TEST_API=1 \ -DCBM_TEXT_EDIT_ENABLE_TEST_API=1 -DCBM_CLI_ENABLE_TEST_API=1 \ - -DCBM_DIAGNOSTICS_ENABLE_TEST_API=1 -DCBM_ENABLE_TEST_SEAMS=1 + -DCBM_DIAGNOSTICS_ENABLE_TEST_API=1 -DCBM_PIPELINE_ENABLE_TEST_API=1 \ + -DCBM_WATCHER_ENABLE_TEST_API=1 -DCBM_ENABLE_TEST_SEAMS=1 +TEST_INCLUDE_FLAGS = -Itests -Itests/repro # The build system is the single source of truth for "is this binary # instrumented": compiler-specific probes (__SANITIZE_ADDRESS__) miss # clang's feature-check spelling and every non-ASan sanitizer, so the @@ -157,17 +208,28 @@ endif # is meaningless for PE, so it is gated rather than made "common". ELF_HARDENING_FLAGS := ifeq ($(IS_LINUX),yes) +# A Linux container may cross-compile a PE binary. IS_LINUX identifies the +# build host, so the compiler-derived target guard is also required. +ifneq ($(IS_MINGW),yes) ELF_HARDENING_FLAGS := -Wl,-z,noexecstack endif +endif # The POSIX wrap shim exists only so the profiler can observe allocations. It # must never reach a sanitized build: the Linux/macOS test builds are CRT+ASan, # and redirecting malloc into mimalloc underneath ASan's own interception mixes # two allocators on the same pointers. Windows keeps its wrap flags everywhere, # because there the shim is what makes mimalloc own the allocations at all. -LDFLAGS = -lm -lstdc++ -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) -LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) -LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz $(TSAN_SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) +LDFLAGS = -lm -lstdc++ -lpthread -lz $(LIBGIT2_LIBS) $(WIN32_LIBS) \ + $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) $(EXTRA_LDFLAGS) +LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(SANITIZE) $(LIBGIT2_LIBS) \ + $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) +LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz $(TSAN_SANITIZE) $(LIBGIT2_LIBS) \ + $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) +# nosan: no ASan/UBSan — required for macOS 'leaks' tool (incompatible with ASan malloc replacement) +CFLAGS_NOSAN = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) -g -O1 +LDFLAGS_NOSAN = -lm -lstdc++ -lpthread -lz $(LIBGIT2_LIBS) $(WIN32_LIBS) \ + $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS) # ── Source files ───────────────────────────────────────────────── @@ -219,7 +281,6 @@ EXTRACTION_SRCS = \ # LSP resolvers (compiled as one unit via lsp_all.c) LSP_SRCS = $(CBM_DIR)/lsp_all.c - # Header/source dependencies of the lsp_all unity object. lsp_all.c #includes # every lsp/*.c resolver, which in turn include cbm.h — and CBMCall is copied # BY VALUE across the lsp -> pipeline boundary (cbm_calls_push). This object is @@ -296,6 +357,7 @@ PIPELINE_SRCS = \ src/pipeline/registry.c \ src/pipeline/pipeline.c \ src/pipeline/pipeline_incremental.c \ + src/pipeline/pipeline_delta.c \ src/pipeline/worker_pool.c \ src/pipeline/pass_parallel.c \ src/pipeline/pass_definitions.c \ @@ -308,6 +370,9 @@ PIPELINE_SRCS = \ src/pipeline/pass_gitdiff.c \ src/pipeline/pass_configures.c \ src/pipeline/pass_configlink.c \ + src/pipeline/pass_normalize.c \ + src/pipeline/httplink.c \ + src/pipeline/pass_httplinks.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ src/pipeline/pass_envscan.c \ @@ -330,6 +395,12 @@ SEMANTIC_SRCS = src/semantic/semantic.c src/semantic/ast_profile.c src/semantic/ # nomic-embed-code pretrained vectors (assembler blob) UNIXCODER_BLOB_SRC = vendored/nomic/code_vectors_blob.S +# Depindex module (dependency/reference API indexing) +DEPINDEX_SRCS = src/depindex/depindex.c + +# PageRank module (node + edge ranking) +PAGERANK_SRCS = src/pagerank/pagerank.c + # Traces module (new) TRACES_SRCS = src/traces/traces.c @@ -337,7 +408,9 @@ TRACES_SRCS = src/traces/traces.c WATCHER_SRCS = src/watcher/watcher.c # Git context module (new) -GIT_SRCS = src/git/git_context.c +GIT_SRCS = src/git/git_command.c \ + src/git/git_context.c \ + src/git/git_snapshot.c # CLI module (new) CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \ @@ -413,7 +486,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources -PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) +PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(DEPINDEX_SRCS) $(PAGERANK_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) @@ -433,7 +506,10 @@ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h tests/*.h tests/repro # ── Test sources ───────────────────────────────────────────────── TEST_FOUNDATION_SRCS = \ - tests/test_main.c \ + tests/test_foundation_main.c \ + $(TEST_FOUNDATION_CASE_SRCS) + +TEST_FOUNDATION_CASE_SRCS = \ tests/test_arena.c \ tests/test_hash_table.c \ tests/test_dyn_array.c \ @@ -447,6 +523,8 @@ TEST_FOUNDATION_SRCS = \ tests/test_private_file_lock.c \ tests/test_lock_registry.c +TEST_MAIN_SRC = tests/test_main.c + TEST_EXTRACTION_SRCS = \ tests/test_extraction.c \ tests/test_extraction_inheritance.c \ @@ -465,7 +543,8 @@ TEST_STORE_SRCS = \ tests/test_store_bulk.c \ tests/test_store_pragmas.c \ tests/test_store_checkpoint.c \ - tests/test_dump_verify_io.c + tests/test_dump_verify_io.c \ + tests/test_schema_declared_property_keys.c TEST_CYPHER_SRCS = \ tests/test_cypher.c @@ -494,7 +573,18 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c +TEST_PIPELINE_SRCS = \ + tests/test_registry.c \ + tests/test_pipeline.c \ + tests/test_cross_repo.c \ + tests/test_fqn.c \ + tests/test_route_canon.c \ + tests/test_path_alias.c \ + tests/test_configlink.c \ + tests/test_infrascan.c \ + tests/test_worker_pool.c \ + tests/test_parallel.c \ + tests/test_index_resilience.c TEST_WATCHER_SRCS = tests/test_watcher.c @@ -548,9 +638,18 @@ TEST_MEM_SRCS = tests/test_mem.c TEST_UI_SRCS = tests/test_ui.c TEST_HTTPD_SRCS = tests/test_httpd.c -TEST_SECURITY_SRCS = tests/test_security.c +TEST_DEPINDEX_SRCS = tests/test_depindex.c + +TEST_PAGERANK_SRCS = tests/test_pagerank.c + +TEST_TOKEN_REDUCTION_SRCS = tests/test_token_reduction.c + +TEST_TOOL_CONSOLIDATION_SRCS = tests/test_tool_consolidation.c +TEST_INPUT_VALIDATION_SRCS = tests/test_input_validation.c +TEST_SECURITY_SRCS = tests/test_security.c TEST_YAML_SRCS = tests/test_yaml.c +TEST_HTTPLINK_SRCS = tests/test_httplink.c TEST_SEMANTIC_SRCS = tests/test_semantic.c TEST_AST_PROFILE_SRCS = tests/test_ast_profile.c @@ -623,8 +722,57 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c -ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) - +ALL_TEST_SRCS = \ + $(TEST_MAIN_SRC) \ + $(TEST_FOUNDATION_CASE_SRCS) \ + $(TEST_EXTRACTION_SRCS) \ + $(TEST_STORE_SRCS) \ + $(TEST_CYPHER_SRCS) \ + $(TEST_MCP_SRCS) \ + $(TEST_DAEMON_SRCS) \ + $(TEST_DISCOVER_SRCS) \ + $(TEST_GRAPH_BUFFER_SRCS) \ + $(TEST_PIPELINE_SRCS) \ + $(TEST_WATCHER_SRCS) \ + $(TEST_LZ4_SRCS) \ + $(TEST_ZSTD_SRCS) \ + $(TEST_ARTIFACT_SRCS) \ + $(TEST_SQLITE_WRITER_SRCS) \ + $(TEST_GO_LSP_SRCS) \ + $(TEST_C_LSP_SRCS) \ + $(TEST_PHP_LSP_SRCS) \ + $(TEST_CS_LSP_SRCS) \ + $(TEST_CS_LSP_BENCH_SRCS) \ + $(TEST_PERL_LSP_SRCS) \ + $(TEST_SCOPE_SRCS) \ + $(TEST_TYPE_REP_SRCS) \ + $(TEST_PY_LSP_SRCS) \ + $(TEST_PY_LSP_BENCH_SRCS) \ + $(TEST_PY_LSP_STRESS_SRCS) \ + $(TEST_PY_LSP_SCALE_SRCS) \ + $(TEST_TS_LSP_SRCS) \ + $(TEST_JAVA_LSP_SRCS) \ + $(TEST_KOTLIN_LSP_SRCS) \ + $(TEST_RUST_LSP_SRCS) \ + $(TEST_TRACES_SRCS) \ + $(TEST_HTTPLINK_SRCS) \ + $(TEST_CLI_SRCS) \ + $(TEST_MEM_SRCS) \ + $(TEST_UI_SRCS) \ + $(TEST_HTTPD_SRCS) \ + $(TEST_DEPINDEX_SRCS) \ + $(TEST_PAGERANK_SRCS) \ + $(TEST_TOKEN_REDUCTION_SRCS) \ + $(TEST_TOOL_CONSOLIDATION_SRCS) \ + $(TEST_INPUT_VALIDATION_SRCS) \ + $(TEST_SECURITY_SRCS) \ + $(TEST_YAML_SRCS) \ + $(TEST_SEMANTIC_SRCS) \ + $(TEST_AST_PROFILE_SRCS) \ + $(TEST_SLAB_ALLOC_SRCS) \ + $(TEST_SIMHASH_SRCS) \ + $(TEST_STACK_OVERFLOW_SRCS) \ + $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── @@ -633,10 +781,10 @@ BUILD_DIR = build/c # ── Object file compilation (grammars need relaxed warnings) ───── # Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings) -GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) -GRAMMAR_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \ +GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -Isrc -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) +GRAMMAR_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -Isrc -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \ $(SANITIZE) -GRAMMAR_CFLAGS_TSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \ +GRAMMAR_CFLAGS_TSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -Isrc -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \ $(TSAN_SANITIZE) # Object files for grammars + ts_runtime + lsp_all + preprocessor @@ -649,25 +797,51 @@ TS_RUNTIME_OBJ_TSAN = $(BUILD_DIR)/tsan_ts_runtime.o LSP_OBJ_TSAN = $(BUILD_DIR)/tsan_lsp_all.o PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o +# Grammar wrappers include generated parser/scanner C files. Compiler-generated +# dependency files keep incremental builds correct after grammar refreshes and +# branch/worktree switches. The v1 stamp forces pre-depfile objects to rebuild +# once; subsequent builds invalidate only the grammar whose included files moved. +GRAMMAR_DEP_STAMP = $(BUILD_DIR)/.grammar-deps-v1 +GRAMMAR_DEPFILES = $(addsuffix .d,$(GRAMMAR_OBJS_TEST) $(GRAMMAR_OBJS_TSAN)) + # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security +.PHONY: test test-par test-focused test-compositional test-repro test-foundation test-tsan \ + test-syntax test-daemon-smoke \ + test-leak test-analyze test-memory test-gmalloc cbm \ + cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck \ + lint-format lint-source-safety install test-runner-nosan security $(BUILD_DIR): mkdir -p $(BUILD_DIR) # ── Foundation-only test (fast, no extraction) ─────────────────── -$(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(LDFLAGS_TEST) +$(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) \ + $(MIMALLOC_OBJ_TEST) $(TS_RUNTIME_OBJ_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) + $(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) \ + $(MIMALLOC_OBJ_TEST) $(TS_RUNTIME_OBJ_TEST) $(LDFLAGS_TEST) test-foundation: $(BUILD_DIR)/test-foundation cd $(CURDIR) && $(BUILD_DIR)/test-foundation +# Fast C syntax check for selected test or production translation units. Reuse +# the nosan test flags so gated *_for_testing declarations and both test include +# roots cannot drift from the real test builds. Example: +# make -f Makefile.cbm test-syntax TEST_SYNTAX_SRCS="tests/test_mcp.c src/mcp/mcp.c" +TEST_SYNTAX_SRCS ?= +test-syntax: + @test -n "$(strip $(TEST_SYNTAX_SRCS))" || \ + (echo "TEST_SYNTAX_SRCS is required for test-syntax"; exit 2) + $(CC) $(CFLAGS_NOSAN) $(TEST_INCLUDE_FLAGS) -fsyntax-only $(TEST_SYNTAX_SRCS) + # ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─ -$(BUILD_DIR)/%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) - $(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $< +$(GRAMMAR_DEP_STAMP): | $(BUILD_DIR) + @touch $@ + +$(GRAMMAR_OBJS_TEST): $(BUILD_DIR)/%.o: $(CBM_DIR)/%.c $(GRAMMAR_DEP_STAMP) | $(BUILD_DIR) + $(CC) $(GRAMMAR_CFLAGS_TEST) -MMD -MP -MF $@.d -c -o $@ $< $(BUILD_DIR)/ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $< @@ -678,8 +852,8 @@ $(BUILD_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR) $(BUILD_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR) $(CXX) $(CXXFLAGS_TEST) -w -I$(CBM_DIR)/vendored -c -o $@ $< -$(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) - $(CC) $(GRAMMAR_CFLAGS_TSAN) -c -o $@ $< +$(GRAMMAR_OBJS_TSAN): $(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c $(GRAMMAR_DEP_STAMP) | $(BUILD_DIR) + $(CC) $(GRAMMAR_CFLAGS_TSAN) -MMD -MP -MF $@.d -c -o $@ $< $(BUILD_DIR)/tsan_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TSAN) -c -o $@ $< @@ -746,6 +920,59 @@ $(BUILD_DIR)/prod_tre.o: $(TRE_SRC) | $(BUILD_DIR) $(CC) $(TRE_CFLAGS) -O2 -c -o $@ $< endif +# ── Nosan build: ASan-free test runner for macOS heap leak detection ───────── +# +# WHY THIS EXISTS: +# 'make test-leak' uses Apple's 'leaks --atExit' tool to find heap leaks. +# But leaks cannot inspect a process that uses a custom malloc (such as ASan). +# The regular test-runner is built with -fsanitize=address,undefined, which +# replaces malloc → leaks aborts with "unable to inspect heap ranges". +# +# HOW IT WORKS: +# We rebuild all ASan-instrumented vendored objects without -fsanitize flags +# into $(NOSAN_DIR), then link test-runner-nosan against them. +# The resulting binary runs the full test suite under Apple's heap profiler. +# Full leak report is written to $(LEAK_LOG) = build/c/leak-report.txt. +# +# HOW TO USE: +# make test-leak # runs full suite + heap check, saves report to LEAK_LOG +# cat build/c/leak-report.txt # review complete leak report after run +# +# WHICH OBJECTS NEED NOSAN VARIANTS (use SANITIZE in their *_TEST flags): +# sqlite3, lsp_all, preprocessor, grammar/*.c, ts_runtime +# WHICH ARE REUSED AS-IS (never use SANITIZE): +# mimalloc (MIMALLOC_CFLAGS_TEST has no -fsanitize) +# tre (only on Windows; TRE_CFLAGS has no -fsanitize) +# +NOSAN_DIR = $(BUILD_DIR)/nosan +GRAMMAR_CFLAGS_NOSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -Isrc -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) +GRAMMAR_OBJS_NOSAN = $(patsubst $(CBM_DIR)/%.c,$(NOSAN_DIR)/%.o,$(GRAMMAR_SRCS)) +GRAMMAR_DEPFILES += $(addsuffix .d,$(GRAMMAR_OBJS_NOSAN)) + +$(NOSAN_DIR): + mkdir -p $(NOSAN_DIR) + +# Grammar C files (tree-sitter parsers) — recompiled without ASan/UBSan +$(GRAMMAR_OBJS_NOSAN): $(NOSAN_DIR)/%.o: $(CBM_DIR)/%.c $(GRAMMAR_DEP_STAMP) | $(NOSAN_DIR) + $(CC) $(GRAMMAR_CFLAGS_NOSAN) -MMD -MP -MF $@.d -c -o $@ $< + +$(NOSAN_DIR)/ts_runtime.o: $(CBM_DIR)/ts_runtime.c | $(NOSAN_DIR) + $(CC) $(GRAMMAR_CFLAGS_NOSAN) -c -o $@ $< + +$(NOSAN_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(NOSAN_DIR) + $(CC) $(GRAMMAR_CFLAGS_NOSAN) -c -o $@ $< + +$(NOSAN_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(NOSAN_DIR) + $(CXX) $(CXXFLAGS_COMMON) -g -O1 -w -I$(CBM_DIR)/vendored -c -o $@ $< + +$(NOSAN_DIR)/sqlite3.o: $(SQLITE3_SRC) | $(NOSAN_DIR) + $(CC) $(SQLITE3_CFLAGS_TEST) -c -o $@ $< + +OBJS_VENDORED_NOSAN = $(MIMALLOC_OBJ_TEST) $(NOSAN_DIR)/sqlite3.o $(TRE_OBJ_TEST) \ + $(GRAMMAR_OBJS_NOSAN) $(NOSAN_DIR)/ts_runtime.o \ + $(NOSAN_DIR)/lsp_all.o $(NOSAN_DIR)/preprocessor.o \ + $(NOSAN_LZ4_OBJ) $(NOSAN_ZSTD_OBJ) $(UNIXCODER_OBJ) + # Vendored LZ4 (test build) LZ4_OBJ_TEST = $(BUILD_DIR)/test_lz4.o $(BUILD_DIR)/test_lz4hc.o LZ4_OBJ_TSAN = $(BUILD_DIR)/tsan_lz4.o $(BUILD_DIR)/tsan_lz4hc.o @@ -766,6 +993,17 @@ $(BUILD_DIR)/test_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c | $(BUILD_DIR) $(BUILD_DIR)/tsan_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c | $(BUILD_DIR) $(CC) -std=c11 -D_DEFAULT_SOURCE -g -O1 $(TSAN_SANITIZE) -w -I$(CBM_DIR)/vendored/zstd -c -o $@ $< +# Vendored LZ4/zstd (NOSAN build — sanitizer-free so `leaks --atExit` can walk the +# heap on macOS; ASan replaces malloc and is incompatible with the leaks tool). +NOSAN_LZ4_OBJ = $(NOSAN_DIR)/lz4.o $(NOSAN_DIR)/lz4hc.o +NOSAN_ZSTD_OBJ = $(NOSAN_DIR)/zstd.o +$(NOSAN_DIR)/lz4.o: $(CBM_DIR)/vendored/lz4/lz4.c | $(NOSAN_DIR) + $(CC) -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -c -o $@ $< +$(NOSAN_DIR)/lz4hc.o: $(CBM_DIR)/vendored/lz4/lz4hc.c | $(NOSAN_DIR) + $(CC) -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR)/vendored/lz4 -c -o $@ $< +$(NOSAN_DIR)/zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c | $(NOSAN_DIR) + $(CC) -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR)/vendored/zstd -c -o $@ $< + # nomic-embed-code pretrained vector blob UNIXCODER_OBJ = $(BUILD_DIR)/unixcoder_blob.o $(UNIXCODER_OBJ): $(UNIXCODER_BLOB_SRC) vendored/nomic/code_vectors.bin | $(BUILD_DIR) @@ -775,12 +1013,19 @@ OBJS_VENDORED_TEST = $(MIMALLOC_OBJ_TEST) $(SQLITE3_OBJ_TEST) $(TRE_OBJ_TEST) $( OBJS_VENDORED_TSAN = $(MIMALLOC_OBJ_TSAN) $(SQLITE3_OBJ_TSAN) $(TRE_OBJ_TSAN) $(GRAMMAR_OBJS_TSAN) $(TS_RUNTIME_OBJ_TSAN) $(LSP_OBJ_TSAN) $(PP_OBJ_TSAN) $(LZ4_OBJ_TSAN) $(ZSTD_OBJ_TSAN) $(UNIXCODER_OBJ) $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TEST) -Itests -Itests/repro -o $@ \ + $(CC) $(CFLAGS_TEST) $(TEST_INCLUDE_FLAGS) -o $@ \ $(ALL_TEST_SRCS) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) +$(BUILD_DIR)/test-runner-nosan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_NOSAN) | $(BUILD_DIR) $(NOSAN_DIR) + $(CC) $(CFLAGS_NOSAN) $(TEST_INCLUDE_FLAGS) -o $@ \ + $(ALL_TEST_SRCS) $(PROD_SRCS) \ + $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ + $(OBJS_VENDORED_NOSAN) \ + $(LDFLAGS_NOSAN) + test: $(BUILD_DIR)/test-runner cd $(CURDIR) && $(BUILD_DIR)/test-runner @@ -798,6 +1043,48 @@ test-focused: $(BUILD_DIR)/test-runner (echo "TEST_SUITES is required for test-focused"; exit 2) cd $(CURDIR) && $(BUILD_DIR)/test-runner $(TEST_SUITES) +# Cross-parent interaction canaries. Keep this target small: each row pairs a +# classifier or policy from one side of the merge with the publication action +# that consumes it, while the full suite remains the exhaustive gate. +test-compositional: $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=pipeline \ + CBM_ONLY_TEST=incremental_frontier_full_fallback_failure_preserves_dirty_ledger \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=input_validation \ + CBM_ONLY_TEST=path_project_autoindex_honors_dep_limit_and_refreshes_rank \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=daemon_application \ + CBM_ONLY_TEST=daemon_application_auto_index_honors_tracked_file_limit \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=pagerank \ + CBM_ONLY_TEST=pagerank_refresh_defer_exact_delta_reindexes_does_not_defer_containment \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=pagerank \ + CBM_ONLY_TEST=pagerank_refresh_defer_all_incremental_reindexes_defers_full_fallback \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=mcp \ + CBM_ONLY_TEST=tool_query_graph_uses_active_relationship_query_with_ready_overlay \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=mcp \ + CBM_ONLY_TEST=watcher_publication_reopens_cached_store_generation \ + $(BUILD_DIR)/test-runner +# These two established stdio-notification oracles use POSIX pipe/alarm +# fixtures and are not registered by tests/test_mcp.c on native Windows. +ifneq ($(OS),Windows_NT) + cd $(CURDIR) && CBM_ONLY_SUITE=mcp \ + CBM_ONLY_TEST=mcp_index_repository_inprocess_sends_list_changed \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=mcp \ + CBM_ONLY_TEST=mcp_autoindex_thread_sends_list_changed \ + $(BUILD_DIR)/test-runner +endif + cd $(CURDIR) && CBM_ONLY_SUITE=daemon_runtime \ + CBM_ONLY_TEST=daemon_runtime_request_cancel_is_exact_and_session_remains_usable \ + $(BUILD_DIR)/test-runner + cd $(CURDIR) && CBM_ONLY_SUITE=mcp \ + CBM_ONLY_TEST=tool_index_repository_lock_wait_honors_request_cancel \ + $(BUILD_DIR)/test-runner + # ── Cumulative bug-reproduction runner (RED by design, non-gating) ── # Mirrors test-runner's link line but uses repro_main.c (own main + counters) # and TEST_REPRO_SRCS instead of ALL_TEST_SRCS. Exits non-zero while any bug is @@ -813,6 +1100,14 @@ test-repro: $(BUILD_DIR)/test-repro-runner cd $(CURDIR) && $(BUILD_DIR)/test-repro-runner # ── TSan full test ─────────────────────────────────────────────── +# +# ThreadSanitizer build for data-race detection in the parallel pipeline. +# Mirrors test-runner but links vendored objects compiled with +# -fsanitize=thread and links the test mimalloc object (MI_OVERRIDE=0 — +# does NOT override malloc, so TSan intercepts the real malloc/free) to +# satisfy the unconditional mi_* calls in src/foundation/mem.c. +# +# Cannot be combined with ASan/UBSan. Uses the system allocator. # Every threaded production surface that runs clean AND stable under TSan: # allocator concurrency (mem, slab_alloc), the parallel extraction worker pool @@ -838,6 +1133,11 @@ test-repro: $(BUILD_DIR)/test-repro-runner TEST_TSAN_SUITES ?= mem slab_alloc parallel worker_pool watcher httpd pipeline \ diagnostics mcp mcp_mutation_guard subprocess daemon daemon_application TSAN_OPTIONS ?= halt_on_error=1 +# Keep the project suppression separate from the caller's ordinary TSan knobs. +# Advanced runs can point this at a combined suppression file without replacing +# halt/report/history settings supplied through TSAN_OPTIONS. +TSAN_PROJECT_OPTIONS ?= suppressions=$(CURDIR)/tests/tsan.supp +TSAN_WORKERS ?= 4 # A fixed concurrent envelope keeps TSan deterministic across developer hosts # and GitHub runners. Four workers still exercise real races without turning # sanitizer-instrumented allocator bookkeeping into a high-core lock convoy. @@ -845,17 +1145,127 @@ TSAN_OPTIONS ?= halt_on_error=1 # invoke test-runner-tsan directly so release gates cannot drift accidentally. $(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TSAN) $(PROJECT_HDRS) | $(BUILD_DIR) - $(CC) $(CFLAGS_TSAN) -Itests -Itests/repro -o $@ \ + $(CC) $(CFLAGS_TSAN) $(TEST_INCLUDE_FLAGS) -o $@ \ $(ALL_TEST_SRCS) $(PROD_SRCS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ $(OBJS_VENDORED_TSAN) \ $(LDFLAGS_TSAN) test-tsan: $(BUILD_DIR)/test-runner-tsan - @echo "ThreadSanitizer workers: 4" - cd $(CURDIR) && CBM_WORKERS=4 TSAN_OPTIONS="$(TSAN_OPTIONS)" \ + @echo "Running ThreadSanitizer with $(TSAN_WORKERS) workers. Reports go to stderr." + cd $(CURDIR) && CBM_WORKERS=$(TSAN_WORKERS) \ + TSAN_OPTIONS="$(TSAN_OPTIONS):$(TSAN_PROJECT_OPTIONS)" \ $(BUILD_DIR)/test-runner-tsan $(TEST_TSAN_SUITES) +# ── Leak detection ─────────────────────────────────────────────── +# macOS: uses `leaks --atExit` (Apple Clang LSan not available on all versions) +# Linux: ASAN_OPTIONS=detect_leaks=1 (GCC/Clang ASan always includes LSan) +# Note: if false positives appear from system libraries on Linux, create lsan.supp +# and set LSAN_OPTIONS=suppressions=lsan.supp +LEAK_LOG = $(BUILD_DIR)/leak-report.txt + +# Stream a long-running gate through tee while preserving both failure sources. +# Make recipes use /bin/sh by default, so Bash-only PIPESTATUS is not portable +# (notably, Debian/Ubuntu /bin/sh is dash). The pipeline's left-hand subshell +# records its status in a per-process sidecar; the parent shell then returns the +# command failure first, or tee's failure when the command itself succeeded. +define run_logged_command + @status_file="$(1).status.$$$$"; \ + trap 'rm -f "$$status_file"' 0 1 2 3 15; \ + { $(2); printf '%s\n' "$$?" > "$$status_file"; } 2>&1 | tee "$(1)"; \ + tee_status=$$?; \ + if test ! -s "$$status_file"; then \ + echo "ERROR: logged command ended without recording its status" >&2; \ + exit 1; \ + fi; \ + command_status=$$(sed -n '1p' "$$status_file"); \ + if test "$$command_status" -ne 0; then exit "$$command_status"; fi; \ + exit "$$tee_status" +endef + +# Apple's leaks debugger stops descendant test binaries, so crash, socket- +# inheritance, and UI child-process probes cannot run under `leaks --atExit` +# without deadlocking or failing their parent. Cover the allocation-owning +# stores, pipelines, rankers, and parallel workers explicitly instead. The test runner +# also always includes httplink, token_reduction, depindex, pagerank, +# tool_consolidation, and input_validation when suite arguments are present. +TEST_LEAK_SUITES ?= arena hash_table dyn_array str_intern store_nodes store_edges \ + store_search store_bulk store_pragmas store_checkpoint dump_verify_io \ + graph_buffer registry pipeline worker_pool parallel slab_alloc mem \ + integration incremental +# Guard Malloc is inherited across exec on macOS. Keep its default gate on +# allocation-owning, in-process surfaces: otherwise external helpers such as +# git are instrumented too, and watcher tests observe libgmalloc/tool behavior +# instead of the server's memory safety. CBM_ONLY_SUITE/CBM_ONLY_TEST still +# allow an explicit narrower probe. +TEST_GMALLOC_SUITES ?= $(TEST_LEAK_SUITES) lz4 zstd artifact sqlite_writer +ifeq ($(UNAME_S),Darwin) +# macOS: 'leaks' cannot inspect ASan-instrumented processes (ASan replaces malloc). +# Use test-runner-nosan (no ASan/UBSan) so leaks can walk the heap. +test-leak: $(BUILD_DIR)/test-runner-nosan + @echo "Running heap leak detection via 'leaks --atExit' on nosan build (macOS). May take 2-5 minutes." + @echo "Full report saved to $(LEAK_LOG). Exit 0 = no leaks." + $(call run_logged_command,$(LEAK_LOG),leaks --atExit -- $(BUILD_DIR)/test-runner-nosan $(TEST_LEAK_SUITES)) +else +test-leak: $(BUILD_DIR)/test-runner + @echo "Running heap leak detection via ASan/LSan (Linux). Full report saved to $(LEAK_LOG). Exit 0 = no leaks." + $(call run_logged_command,$(LEAK_LOG),ASAN_OPTIONS=detect_leaks=1 $(BUILD_DIR)/test-runner) +endif + +# ── Memory-corruption debug (macOS) ─────────────────────────────── +# Catches uninit reads, use-after-free, and overruns that ASan/TSan can miss +# (used to investigate the B1 custom-writer non-deterministic corruption). +# All run the NOSAN binary: ASan replaces malloc, which would defeat these +# libmalloc knobs. Set CBM_ONLY_SUITE= to target a slow suite, and +# optionally CBM_ONLY_TEST= to run matching tests after suite setup. +MEM_LOG = $(BUILD_DIR)/mem-report.txt +ifeq ($(UNAME_S),Darwin) +# MallocScribble=1 → freed memory filled with 0x55 (catches use-after-free). +# MallocPreScribble=1 → freshly-allocated memory filled with 0xAA (catches +# uninitialized reads — the macOS MSan equivalent; makes uninit deterministic). +test-memory: $(BUILD_DIR)/test-runner-nosan + @echo "Running under MallocScribble+MallocPreScribble (macOS nosan): uninit reads -> 0xAA, use-after-free -> 0x55." + @echo "Full report saved to $(MEM_LOG)." + $(call run_logged_command,$(MEM_LOG),MallocScribble=1 MallocPreScribble=1 $(BUILD_DIR)/test-runner-nosan) + +# Guard Malloc (libgmalloc): a guard page around EVERY allocation → crashes at +# the exact overrun / use-after-free, with a stack trace. Stricter than scribble +# (which only paints bytes); slower and noisier. The decisive memory tool. +test-gmalloc: $(BUILD_DIR)/test-runner-nosan + @echo "Running allocation-owning suites under Guard Malloc (libgmalloc). Crashes at the exact overrun/UAF. Slow." + @echo "Full report saved to $(MEM_LOG)." + $(call run_logged_command,$(MEM_LOG),DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib $(BUILD_DIR)/test-runner-nosan $(TEST_GMALLOC_SUITES)) +else +test-memory test-gmalloc: $(BUILD_DIR)/test-runner + @echo "These targets are macOS-only (MallocScribble/libgmalloc). On Linux use 'make test-leak' (ASan/LSan), or build with -fsanitize=memory (MSan) for uninit detection." +endif + +# ── Static analysis (Clang analyzer only — GCC has no --analyze flag) ────── +ifeq ($(IS_GCC),no) +# EDITOR_TEST_DEFINES is required, not optional: the test sources call the +# *_for_testing seams that those defines gate. Analyzing without them makes +# every such call an implicit declaration returning int, which then reports as +# a "call to undeclared function" error plus downstream int-to-pointer errors, +# and poisons the analyzer's type reasoning for the whole translation unit. +ANALYZE_LOG = $(BUILD_DIR)/analyze-report.txt +test-analyze: $(ALL_TEST_SRCS) $(PROD_SRCS) | $(BUILD_DIR) + @echo "Running Clang static analyzer..." + @$(CC) --analyze $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) $(TEST_INCLUDE_FLAGS) \ + $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) >"$(ANALYZE_LOG)" 2>&1; \ + analyze_status=$$?; \ + if grep -E "warning:|error:|note:" "$(ANALYZE_LOG)"; then \ + :; \ + elif [ $$analyze_status -eq 0 ]; then \ + echo "No issues found."; \ + else \ + cat "$(ANALYZE_LOG)"; \ + fi; \ + exit $$analyze_status +else +test-analyze: + @echo "Static analysis skipped: requires Clang (not GCC). Install clang and re-run." +endif + # Real-binary POSIX lifecycle smoke. The endpoint is deliberately account-wide; # an explicit Make invocation requires a clean rendezvous and fails rather than # silently skipping an occupied one. Deterministic C tests cover Windows IPC. @@ -869,9 +1279,12 @@ GRAMMAR_OBJS_PROD = $(patsubst $(CBM_DIR)/%.c,$(BUILD_DIR)/prod_%.o,$(GRAMMAR_SR TS_RUNTIME_OBJ_PROD = $(BUILD_DIR)/prod_ts_runtime.o LSP_OBJ_PROD = $(BUILD_DIR)/prod_lsp_all.o PP_OBJ_PROD = $(BUILD_DIR)/prod_preprocessor.o +GRAMMAR_DEPFILES += $(addsuffix .d,$(GRAMMAR_OBJS_PROD)) -$(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) - $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $< +-include $(GRAMMAR_DEPFILES) + +$(GRAMMAR_OBJS_PROD): $(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c $(GRAMMAR_DEP_STAMP) | $(BUILD_DIR) + $(CC) $(GRAMMAR_CFLAGS) -MMD -MP -MF $@.d -c -o $@ $< $(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $< @@ -926,6 +1339,18 @@ $(BUILD_DIR)/codebase-memory-mcp: $(MAIN_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $( cbm: $(BUILD_DIR)/codebase-memory-mcp @echo "Built: $(BUILD_DIR)/codebase-memory-mcp" + $(call codesign_binary,$(BUILD_DIR)/codebase-memory-mcp) + +# ── Install to INSTALL_DIR (default ~/.local/bin) ──────────────── +# Re-signs after copy — required on macOS 25+ where cp invalidates the +# existing ad-hoc signature and an unsigned binary gets SIGKILL at startup. +INSTALL_DIR ?= $(HOME)/.local/bin +install: cbm + @echo "Installing to $(INSTALL_DIR)/codebase-memory-mcp ..." + install -d "$(INSTALL_DIR)" + install -m 755 "$(BUILD_DIR)/codebase-memory-mcp" "$(INSTALL_DIR)/codebase-memory-mcp" + $(call codesign_binary,$(INSTALL_DIR)/codebase-memory-mcp) + @echo "Done. Run: $(INSTALL_DIR)/codebase-memory-mcp" # ── Build with embedded UI (requires Node.js) ─────────────────── @@ -981,11 +1406,14 @@ lint-tidy: @echo "=== clang-tidy ===" @$(CLANG_TIDY) --quiet $(LINT_SRCS) -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) -# cppcheck: complementary analysis (config in .cppcheck) +# cppcheck: complementary analysis (config in .cppcheck). Analyze the +# failpoint-enabled superset so test-only allocation and cleanup paths remain +# checked instead of collapsing to production constant-false stubs. lint-cppcheck: @echo "=== cppcheck ===" @$(CPPCHECK) --enable=warning,style,performance,portability \ --std=c11 --language=c \ + -DCBM_ENABLE_TEST_SEAMS \ --suppressions-list=.cppcheck \ --error-exitcode=1 \ --inline-suppr \ @@ -1023,6 +1451,11 @@ lint-no-suppress: fi @echo " Checking NOLINT(misc-no-recursion) against whitelist..." @scripts/check-nolint-whitelist.sh + @bash scripts/check-source-safety.sh + +lint-source-safety: + @bash scripts/check-source-safety.sh + @bash scripts/test-source-safety.sh # All linters (run with make -j3 lint for parallel execution) lint: lint-tidy lint-cppcheck lint-format lint-no-suppress diff --git a/README.md b/README.md index 7a32ce401..22303e779 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 single static binary 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 158 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. Zero dependencies. Plug and play across 43 supported automatic/conditional client surfaces. +High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 158 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. MCP exposes a streamlined default tool set with advanced tools available on demand. Zero dependencies. Plug and play across 11 coding agents. > **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. @@ -32,12 +32,12 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si - **Extreme indexing speed** — Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing. - **Plug and play** — single static binary for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). No Docker, no runtime dependencies, no API keys. Download → `install` → restart agent → done. -- **158 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks. +- **156 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks. - **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles. -- **43 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. +- **One command across supported agents** — `install` auto-detects Claude Code, Claude Desktop, Codex CLI, Gemini CLI, Qwen Code, ForgeCode, Zed, OpenCode, Antigravity, Aider, standalone Kilo, the legacy Kilo VS Code extension, VS Code, Cursor, Windsurf, OpenClaw, Kiro, and Junie, then adds only the MCP entries, owned instruction blocks, skills, and hooks each client supports. - **Built-in graph visualization** — 3D interactive UI at `localhost:9749` (optional UI binary variant). - **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. +- **16 MCP tools** (classic mode; a streamlined subset is the default) — search, trace, architecture, impact analysis, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. ## Quick Start @@ -143,13 +143,13 @@ Open `http://localhost:9749` in your browser. The UI is owned by the shared coor ### Auto-Index -Enable automatic indexing on MCP session start: +Enable automatic indexing at MCP session startup or first graph-backed use: ```bash codebase-memory-mcp config set auto_index true ``` -When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`. +When enabled, new projects are indexed automatically at startup or first graph-backed use. With `auto_watch=true`, indexed projects are registered with the background watcher for Git-based change detection; refreshes use the configured reindex policy. Configurable file limit: `config set auto_index_limit 50000`. Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing. @@ -221,7 +221,7 @@ The install script placed beside the binary is **reported, not deleted** — uni - `SEMANTICALLY_RELATED` (vocabulary-mismatch, same-language, score ≥ 0.80) ### Indexing pipeline -- **158 vendored tree-sitter grammars** compiled into the binary +- **156 vendored tree-sitter grammars** compiled into the binary - **Generic package / module resolution** — bare specifiers like `@myorg/pkg`, `github.com/foo/bar`, `use my_crate::foo` resolved via manifest scanning (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `composer.json`, `pubspec.yaml`, `pom.xml`, `build.gradle`, `mix.exs`, `*.gemspec`) - **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, Kustomize overlays as graph nodes - **[Hybrid LSP semantic type resolution](#hybrid-lsp)** for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — a lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers including tsserver / typescript-go, pyright, gopls, Roslyn, Eclipse JDT, and rust-analyzer (parameter binding, return-type inference, generic substitution, JSX component dispatch, JSDoc inference for plain JS files, namespace + trait + late-static-binding resolution for PHP, file-scoped namespaces + records + LINQ method syntax for C#, class-hierarchy + overload + lambda resolution for Java, extension-function + scope-function resolution for Kotlin, trait-method + UFCS resolution for Rust) @@ -229,22 +229,22 @@ The install script placed beside the binary is **reported, not deleted** — uni ### Distribution & operation - **Single static binary, zero infrastructure**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/` -- **Auto-sync**: Background watcher detects file changes and re-indexes automatically +- **Auto-sync**: Background watcher detects git changes and re-indexes automatically when configured - **Route nodes**: REST endpoints are first-class graph entities -- **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'` +- **CLI mode**: `codebase-memory-mcp cli search_graph --project my-project --name-pattern '.*Handler.*'` - **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install` ## Team-Shared Graph Artifact Commit a single compressed file to your repo and your teammates skip the reindex. -`.codebase-memory/graph.db.zst` is a zstd-compressed snapshot of the knowledge graph that lives next to your source. When you index, the artifact is written or refreshed; when a teammate clones the repo and runs `codebase-memory-mcp` for the first time, the artifact is decompressed and incremental indexing fills in their local diff. +`.codebase-memory/graph.db.zst` is a zstd-compressed snapshot of the knowledge graph that lives next to your source. When you index with persistence enabled, the artifact is written or refreshed; when a teammate clones the repo and runs `codebase-memory-mcp` for the first time, the artifact can bootstrap their local graph before any configured refresh. - **Format**: SQLite database, indexes stripped, `VACUUM INTO` compacted, then zstd 1.5.7 compressed (8–13:1 ratio typical) - **Two tiers**: - **Best** (`zstd -9` + index strip + `VACUUM INTO`) — written on explicit `index_repository` - - **Fast** (`zstd -3`) — written by the watcher for low-latency incremental updates -- **Bootstrap**: when no local DB exists but the artifact is present, `index_repository` imports the artifact first, then runs incremental indexing — avoiding the full reindex cost + - **Fast** (`zstd -3`) — written by the watcher when it refreshes an existing artifact +- **Bootstrap**: when no local DB exists but the artifact is present, `index_repository` imports the artifact first, then applies the configured refresh policy - **No merge pain**: a `.gitattributes` line with `merge=ours` is auto-created on first export, so concurrent edits don't produce conflicts on the binary artifact - **Optional**: never committed unless you want it. Add `.codebase-memory/` to `.gitignore` if you prefer everyone to reindex from scratch. @@ -270,6 +270,11 @@ Agent: presents the call chain in plain English Benchmarked on Apple M3 Pro: +Reproducible experiment runs use the fact-table contract in +[Benchmark Experiments](docs/BENCHMARK_EXPERIMENTS.md) and the normative terms in +[Benchmark Terminology](docs/BENCHMARK_TERMINOLOGY.md). The latter distinguishes +lifecycle wall time from overlapping component work before any ratio is reported. + | Operation | Time | Notes | |-----------|------|-------| | **Linux kernel full index** | **3 min** | 28M LOC, 75K files → 4.81M nodes, 7.72M edges | @@ -394,6 +399,19 @@ build/c/test-runner --list-suites # what is available scripts/package-release.sh ``` +The standard and release pathways use the Makefile's optimized production defaults +(`-O2`). For an inspectable local development binary, append debug flags through the +same build entry point; the final override wins over the production optimization: + +```bash +scripts/build.sh EXTRA_CFLAGS="-g -O0 -fno-omit-frame-pointer" \ + EXTRA_CXXFLAGS="-g -O0 -fno-omit-frame-pointer" +``` + +Use `make -f Makefile.cbm test`, `test-tsan`, or `test-leak` for sanitizer and +lifecycle validation; those targets already select their purpose-built compiler and +allocator configurations. + ### Manual MCP Configuration
@@ -412,33 +430,23 @@ 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 +its tools listed. The streamlined subset is the default; run +`codebase-memory-mcp config set tool_mode classic` for all 16. Persisted +configuration changes the live shared daemon, while a process environment +override applies only when it is present in the daemon process that serves the +session.
## Multi-Agent Support -`install` configures 43 client surfaces: 37 detected automatically and 6 -conditional or explicit. “Conditional” means the installer writes only when the +`install` configures 43 supported automatic/conditional client surfaces: 37 detected +automatically and 6 conditional or explicit. “Conditional” means the installer writes only when the documented platform or an explicit, already-existing config path proves the target is active. It never flips experimental feature flags, enables plugins, YOLO modes, global permission bypasses, or third-party instruction trust. -Where a client has a documented custom-agent format, the installer creates three -exact-owned definitions from one canonical contract: - -- **Scout (Tier 1)** — about 3–4 narrow calls for fast positive, provisional discovery; no absence, exhaustive-impact, or dead-code claims. -- **Verify (Tier 2, default)** — task-directed graph evidence, exact source checks, path coverage for every cited file, and scope coverage before negative claims. -- **Auditor (Tier 3)** — bounded scope, current index generation, complete relevant pagination, broader relationship checks, and explicit unresolved limitations. - -Every direct tier batches `check_index_coverage` for its evidence paths and reads -flagged ranges or skipped/excluded files directly. A clean coverage result means -only “no recorded gap,” never proof of completeness. Clients without safe child -MCP access receive the same three tiers as parent-handoff agents; the parent must -supply project, generation, pagination state, graph evidence, and coverage -results. Updates migrate only byte-identical prior Verify definitions and never -overwrite user-modified agents. - | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| | Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`, and post-`Read` coverage | @@ -462,7 +470,7 @@ overwrite user-modified agents. | Warp | Detected, skill only | UI, Warp Drive, or per invocation (manual) | Shared `~/.agents/skills/codebase-memory/SKILL.md` | | Qwen Code | Detected | `.qwen/settings.json` | `QWEN.md`, skill, three explicit read/graph-tool agents; `SessionStart`, `SubagentStart`, and post-`ReadFile` coverage | | GitHub Copilot CLI | Detected | `$COPILOT_HOME/mcp-config.json` | Instructions, skill, three read-only agents; `sessionStart` + `subagentStart` | -| Factory Droid | Detected | `.factory/mcp.json` | `AGENTS.md`, skill, three droids with exact per-tier graph-tool lists (without additive whole-server exposure); `SessionStart` + post-`Read` coverage on macOS/Linux, withheld on Windows | +| Factory Droid | Detected | `.factory/mcp.json` | `AGENTS.md`, skill, three droids with exact per-tier graph-tool lists; `SessionStart` + post-`Read` coverage on macOS/Linux, withheld on Windows | | Crush | Detected | `.config/crush/crush.json` | Managed context path with explicit parent-to-child handoff | | Goose | Detected | `.config/goose/config.yaml` | `.goosehints` | | Mistral Vibe | Detected | `$VIBE_HOME/config.toml` | `AGENTS.md`, skill, and three matched agent/prompt pairs with explicit read-only graph-tool allowlists | @@ -485,87 +493,20 @@ overwrite user-modified agents. | IBM Bob IDE | Conditional | Existing `~/.bob/mcp.json` | Shared rule + IDE skill; no invented hook or agent | | Sourcegraph Cody | Explicit opt-in | Existing `$CBM_CODY_CONFIG_PATH` | MCP only | -### Sessions, compaction, and subagents - -Hooks installed by this project are fail-open and context-only. Claude Code's -`PreToolUse` observes `Grep`/`Glob` and injects matching graph symbols as -`additionalContext`; `PostToolUse` on `Read` adds targeted coverage context when -the graph could not fully parse or index that file. It never denies or replaces -the requested tool call. - -Claude Code, Codex CLI, Qwen Code, GitHub Copilot CLI, and VS Code's Copilot -runtime receive paired session/subagent context where the vendor exposes a -documented context-output contract. Codex users must review and trust installed -hooks through `/hooks`; changing a hook definition changes its trust hash, so an -update can require re-trust. Qoder uses `SessionStart`, `SubagentStart`, and -post-`Read` coverage, including its documented PowerShell executor on Windows. -Kimi uses `UserPromptSubmit`, while Hermes uses `pre_llm_call`; both retain their -documented Windows execution paths. Devin installs -`UserPromptSubmit` and `PostCompaction` on macOS/Linux and adds `SessionStart` -only when Claude's equivalent managed hook is not present. GitLab Duo gets a -narrowly scoped macOS/Linux user `SessionStart` entry on its experimental hook -surface. GitLab Duo, Devin, and Factory hooks are withheld on Windows -because those vendors do not document a deterministic shell/executor contract -there. Gemini CLI, Factory Droid, and Augment also add documented post-read/view -coverage context but expose no equivalent documented child-start context. - -For runtimes without a stable context-producing lifecycle event, durable files -carry the contract across fresh sessions and compaction: verify the graph project -and index freshness, query structural facts in the parent, then pass the project, -qualified symbols, paths, and call-chain evidence in every delegated task. -Claude, Codex, Gemini, Kiro, Qwen, Copilot, CodeBuddy, OpenCode, Kilo, Vibe, -Qoder, Junie, and Factory receive Scout, Verify, and Auditor graph profiles. -Kiro embeds this MCP server with `--tool-profile scout` for Scout and -`--tool-profile analysis` for Verify/Auditor. Junie registers equivalent named -server aliases because its subagent schema filters by server rather than by -individual tool. Both process profiles use positive allowlists: Scout exposes -seven fast inspection tools, Analysis exposes eleven, and future or mutating -tools remain unavailable until explicitly reviewed. If either Junie alias -collides with user configuration, the installer preserves it and installs -parent-handoff profiles instead. Qoder combines its documented named-server -selection with exact tier-specific MCP tool IDs. Factory uses exact registered -MCP tool IDs without its additive `mcpServers` field, which would expose the -whole server. Codex, Kilo, Vibe, and other capable formats likewise enumerate -the narrowest supported tool set. Rovo, Cursor, Augment, Pochi, and Cline use parent handoff where direct -child MCP is unavailable or unsafe; Pochi is limited to `readFile`, and Cline -child agents cannot use MCP. - -Cline's file hooks auto-activate when present, and current Cline does not -reliably consume their context output, so automatic adapters are withheld and -older owned adapters are cleaned up. CodeBuddy's beta, version-gated hooks are -not auto-installed. Junie's EAP -`SessionStart` output is documented as ignored, so no context hook is installed. -Junie custom agents remain EAP-dependent. Qoder can resolve higher-priority -project or plugin agents before user agents with the same name; reload the -client after installation or profile changes. -Cursor context -hooks are withheld: session context injection has a known race, `subagentStart` -is control-only, and read-only subagents cannot safely receive MCP access. Rovo -has no documented session context-output hook, and Bob -documents neither a suitable hook nor a custom-agent surface. Those surfaces are -not approximated with invented augmentation. Kimi plugins, Amp plugins, and -GitLab experimental global skills remain opt-in. - -OpenClaw reinjects the `Codebase Knowledge Graph (codebase-memory-mcp)` AGENTS -section after compaction and places the same guidance in `TOOLS.md`, the bootstrap -files inherited by its subagents. Automatic augmentation covers the active/default -workspace. Separate `agents.list[].workspace` directories require making that -workspace active for installation or copying the managed block there. - -The installed Claude shim is named `cbm-code-discovery-gate` for backward -compatibility; despite the legacy name, it never gates or blocks. - -### Manual or UI-managed integrations - -These are intentionally not counted as automatic installs: Qodo MCP is added -through its UI and may be governed by enterprise allowlists; Warp MCP is managed -through Warp Drive/UI or per invocation (only the shared skill is automatic); -JetBrains AI Assistant / ACP is IDE-managed; GitHub Copilot coding agent, Jules, -and CodeRabbit are cloud/repository-managed; Replit exposes a remote/service -integration rather than a stable local user-global client; BLACKBOX AI does not -document a stable arbitrary user-global MCP/instruction/agent schema; Plandex has -no stable global registry safe to mutate; and SWE-agent uses explicit YAML and is -no longer a suitable automatic global target. +Claude Desktop is additionally supported as a detected MCP-only desktop +surface through its platform `claude_desktop_config.json`; it is outside the +43 coding-agent registry matrix above. + +**Hooks are structurally non-blocking** (exit code 0, every failure path). +For Claude Code, the non-blocking `PreToolUse` augmenter observes `Grep`, `Glob`, +and `Read`. It injects graph matches for searches and indexing-coverage notes for +reads as `additionalContext`; it never denies the underlying tool call. For Codex, +Gemini CLI, and Antigravity, a `SessionStart` hook +injects a one-line code-discovery reminder as session context (Gemini CLI also +keeps its `BeforeTool` reminder). +The installed Claude shim file is named `cbm-code-discovery-gate` for +backward compatibility with existing installs; despite the legacy name it +never gates and never blocks. ## CLI Mode @@ -599,7 +540,7 @@ JSON arguments can also be piped on stdin. Inline JSON remains accepted for back | Tool | Description | |------|-------------| -| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. | +| `index_repository` | Index a repository into the graph. Auto-sync can refresh it after that when configured. | | `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. | @@ -609,7 +550,7 @@ JSON arguments can also be piped on stdin. Inline JSON remains accepted for back | Tool | Description | |------|-------------| | `search_graph` | Structured search by label, name pattern, file pattern, degree filters. Pagination via limit/offset. | -| `trace_path` | BFS traversal — who calls a function and what it calls (alias: `trace_call_path`). Depth 1-5. | +| `trace_path` | BFS traversal — who calls a function and what it calls. Depth 1-5. | | `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. | @@ -639,13 +580,13 @@ JSON arguments can also be piped on stdin. Inline JSON remains accepted for back `query_graph` is a read-only openCypher subset: -- **Clauses**: `MATCH`, `OPTIONAL MATCH`, multiple `MATCH`, `WHERE`, `WITH` (+ `WITH … WHERE`), `RETURN`, `ORDER BY`, `SKIP`, `LIMIT`, `DISTINCT`, `UNWIND`, `UNION` / `UNION ALL`, `CASE`. +- **Clauses**: `MATCH`, `OPTIONAL MATCH`, multiple `MATCH`, `WHERE`, `WITH` (+ `WITH … WHERE` and later match stages), `RETURN`, multi-key `ORDER BY` on projected fields or aliases, `SKIP`, `LIMIT`, `DISTINCT`, `UNWIND`, `UNION` / `UNION ALL`, `CASE`. - **Patterns**: labelled nodes, label alternation `(n:A|B)`, relationship types/direction, variable-length paths `[*1..3]`, inline property maps. - **WHERE**: `= <> < <= > >=`, `AND/OR/XOR/NOT`, `IN`, `CONTAINS`, `STARTS WITH`, `ENDS WITH`, `IS [NOT] NULL`, regex `=~`, label test `n:Label`, and `EXISTS { (n)-[:TYPE]->() }` (single-hop existence — great for dead-code, e.g. `WHERE NOT EXISTS { (f)<-[:CALLS]-() }`). -- **Aggregates**: `count` (+`DISTINCT`), `sum`, `avg`, `min`, `max`, `collect`. +- **Aggregates**: `count`, `sum`, `avg`, `min`, `max`, `collect` (all accept `DISTINCT` arguments). - **Functions**: `labels`, `type`, `id`, `keys`, `properties`; `toLower/toUpper/toString/toInteger/toFloat/toBoolean`; `size`, `length`, `trim/ltrim/rtrim`, `reverse`; `coalesce`, `substring`, `replace`, `left`, `right`. -Anything outside this subset (write/`MERGE`/`CALL` clauses, unsupported functions, list/map literals, comprehensions, path functions, parameters) **fails with a clear `unsupported …` error** rather than returning empty results. +Anything outside this subset (write/`MERGE`/`CALL` clauses, unsupported functions, list/map literals, comprehensions, path functions, parameters) **fails with a clear `unsupported …` error** rather than returning empty results. Valid queries that match zero rows return a hint naming any label or relationship type not present in that project's graph, plus a short summary of the vocabulary that is. ## Ignoring Files @@ -656,13 +597,34 @@ See [docs/cbmignore.md](docs/cbmignore.md) for the full `.cbmignore` how-to: syn ## Configuration ```bash -codebase-memory-mcp config list # show all settings -codebase-memory-mcp config set auto_index true # auto-index on session start +codebase-memory-mcp config list # show common effective settings +codebase-memory-mcp config describe pagerank_damping # show default, accepted extent, and tuning guidance +codebase-memory-mcp config set auto_index true # auto-index on startup/first use codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index +codebase-memory-mcp config set tool_mode streamlined # concise surface; reveal advanced tools on demand +codebase-memory-mcp config set auto_index_deps true # index installed dependency APIs +codebase-memory-mcp config set auto_dep_limit 20 # import-ranked dependency package cap; 0=unlimited +codebase-memory-mcp config set dep_max_files 1000 # per-package source-file cap; 0=unlimited +codebase-memory-mcp config preset list # list named capability/API configurations +codebase-memory-mcp config preset apply streamlined-automatic-dependency-source-indexing-disabled +codebase-memory-mcp config preset apply streamlined-automatic-dependency-source-indexing-enabled codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set default_response_format json # full JSON objects instead of compact TOON tables codebase-memory-mcp config reset auto_index # reset to default ``` +Normal streamlined exploration uses the core tools without a reveal; first-use +indexing and first-response codebase context are automatic when configured. Use +`_hidden_tools` only for explicit advanced operations such as `check_index_coverage`, +`index_repository`, or `index_dependencies`. Classic mode advertises those tools +directly and uses `search_graph`, then `trace_path`, then `get_code_snippet` for +structural discovery. Automatic repository indexing obeys +`auto_index`/`auto_index_limit`; automatic dependency indexing obeys +`auto_index_deps`/`auto_dep_limit`/`dep_max_files` and is disabled by default. +Packages above `dep_max_files` are skipped rather than partially indexed. Explicit +`index_dependencies` calls remain available; disabling automation does not delete +dependency projects that are already indexed. + ### Environment Variables | Variable | Default | Description | @@ -747,14 +709,14 @@ codebase-memory-mcp ships a **lightweight C implementation of language type-reso **Two-layer architecture:** -1. **Tree-sitter pass** — fast, syntactic, runs for every one of the 158 languages. Extracts definitions, calls, imports. +1. **Tree-sitter pass** — fast, syntactic, runs for every one of the 156 languages. Extracts definitions, calls, imports. 2. **Hybrid LSP pass** — type-aware, runs above the tree-sitter pass per-language. Refines call edges using the import graph plus a per-file or pre-built cross-file definition registry. Languages without a Hybrid LSP pass yet fall back to textual resolution, so you always get *some* answer. The result is a knowledge graph accurate enough to drive `trace_path` across packages, inheritance hierarchies, and stdlib calls — without paying for a language server process per project. ## Language Support -158 languages, all parsed via vendored tree-sitter grammars compiled into the binary. Benchmarked against 64 real open-source repositories (78 to 49K nodes): +156 languages, all parsed via vendored tree-sitter grammars compiled into the binary. Benchmarked against 64 real open-source repositories (78 to 49K nodes): | Tier | Score | Languages | |------|-------|-----------| @@ -770,7 +732,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 (16 classic tools, JSON-RPC 2.0, session detection, auto-index) cli/ Install/uninstall/update/config (43 client surfaces, hooks, instructions) store/ SQLite graph storage (nodes, edges, traversal, search, Louvain) pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → config → tests) @@ -780,7 +742,7 @@ src/ traces/ Runtime trace ingestion ui/ Embedded HTTP server + 3D graph visualization foundation/ Platform abstractions (threads, filesystem, logging, memory) -internal/cbm/ Vendored tree-sitter grammars (158 languages) + AST extraction engine +internal/cbm/ Vendored tree-sitter grammars (156 languages) + AST extraction engine ``` ## Security diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..c0d48c2b8 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,120 @@ +# Benchmark tooling + +This directory owns the benchmark implementations, active schemas, machine-readable +terminology, configuration-spelling compatibility data, and source fixtures. + +Primary entry points: + +- `run_benchmark.py`: run one isolated benchmark and emit canonical fact tables. +- `run_experiments.py`: build and execute immutable, resumable benchmark matrices. +- `summarize_results.py`: render quality-gated Markdown from retained result JSON. +- `fact_comparisons.py`: derive parity, capability-delta, and lifecycle tables from + canonical facts. +- `autotune.py`: run the isolated PageRank tuning experiment. + +Start with the built-in help: + +```sh +uv run python benchmarks/run_benchmark.py --help +uv run python benchmarks/run_experiments.py --help +``` + +`run_benchmark.py` is the single-run entry point. `run_experiments.py` is the +multi-candidate, repeated-run entry point; automatic modes store durable ignored +state under `.worktrees/benchmark-campaign/`. Explicit runs should use an ignored +`benchmark-results/` root or another durable path outside the checkout. + +The automatic `--quick` and `--full` matrices default to MCP transport. Cross-build +CLI matrices require a dedicated OS account/runtime or a quiescent account-wide CBM +daemon: current one-shot CLI commands enforce the same exact-build cohort as MCP and +correctly reject a candidate that differs from an active daemon. MCP matrices require +an isolated account/runtime or one active daemon compatible with every candidate. +The runner does not silently change transports. Neither entry point requires Docker. + +For example, this runs the full repeated matrix against the exact local `main` ref: + +```sh +uv run python benchmarks/run_experiments.py --full --transport cli \ + --candidate-ref upstream-main=main \ + --experiment-root /durable/path/full-head-vs-main +``` + +Run that command only after verifying that its OS account has no active CBM daemon, +or from a dedicated benchmark account. Merely changing `CBM_CACHE_DIR`, the Git +worktree, or the experiment root does not create a separate daemon cohort. + +`upstream-main` is the stable candidate role used by the report schema. Overriding +its ref does not rename the role, so cite the resolved ref and commit recorded in the +expanded plan when describing results. + +For ongoing development, a compact `--matrix-spec` may use arbitrary candidate +`{"label": "...", "ref": "branch-or-commit"}` entries. The runner resolves, +production-builds, hashes, and archives those candidates before expanding the +existing immutable plan schema. Profiles remain the reusable configuration axis: +use `config_overrides` for product config keys, `product_environment` for explicit +`CBM_*` process knobs such as `CBM_WORKERS`, and `benchmark_args` for additive +workload flags. Candidate, profile, and scenario scopes can add new branches, +capabilities, and controlled sweeps without editing the built-in dated presets. +Top-level `build_environment` is shared across candidates and accepts only `CC`, +`CXX`, `EXTRA_CFLAGS`, and `EXTRA_CXXFLAGS`; probes and builds retain those values +in candidate identity, and arbitrary environment keys are rejected. +New candidate worktrees default to `/.worktrees/benchmark-candidates`. +Use one `--candidate-root` to select a different writable primary and repeat +`--candidate-search-root` to reuse exact, clean, registered worktrees from moved +locations. Search roots are checked in argument order and never receive new +worktrees or harness metadata; a selected existing worktree may have its ordinary +`build/` output refreshed to verify the requested toolchain identity. +See [the complete matrix example](../docs/BENCHMARK_EXPERIMENTS.md#reusable-ref-based-matrices). + +For cross-build measurements while the host daemon remains active, use the native +container coordinator. It creates an exact Git bundle, runs the same experiment +runner with no host bind mounts, and exports immutable results back to the named +history: + +```sh +uv run python benchmarks/run_container_experiment.py \ + --matrix-spec /absolute/path/development-comparison.json \ + --experiment-root /durable/ignored/path/development-comparison \ + --cpus 4 --memory 8g --workers 4 +``` + +CPU, memory, and worker budgets are required rather than guessed. Candidate builds +use the complete declared CPU budget by default (`--cpus 16` runs `make -j16`); +fractional CPU budgets round up to avoid leaving an available execution slot idle. +Use `--build-jobs N` only when build-memory pressure requires a smaller positive +override. The resolved value is part of the run identity and environment manifest. +The repository-relative `.worktrees/benchmark-candidates` default, build outputs, caches, +daemon state, and result generation remain on two labeled Docker volumes; the +coordinator prints their exact names and retains them for auditable resume. Rerun +the same source spec and experiment root to resume, or remove the printed volumes +after exported results are verified. The measured container is +always native `arm64` or `amd64`, resource bounded, and removed on success or failure. +Each invocation writes a content-addressed container-environment manifest, so changed +arguments create a new audit record instead of replacing history. Failed candidate +build logs are exported under `container-failures//build-logs/`; if +that export itself fails, the error identifies the retained work volume and path. +Each measured source/spec/resource cohort is isolated under +`runsets//`; `--audit-only` resolves to the same cohort and cannot create +a second attempt. Canonical Git ref/commit content identifies the repository +snapshot even when equivalent bundle pack bytes differ; the exact bundle SHA-256 +remains in the manifest. A different snapshot, spec, or resource budget cannot be +misreported as an unplanned cell in the current runset. +Container numbers are controlled Linux relative comparisons, not absolute macOS +latency. See [Container isolation](../docs/BENCHMARK_EXPERIMENTS.md#container-isolation). +Docker benchmarks default to Clang 18.1.3. The pinned image also provides GCC for +explicit portability or compiler-ablation cohorts; override both `CC` and `CXX` +together in `build_environment`. Run Clang and GCC as distinct named histories +with otherwise identical specs, and never infer cross-cohort comparability from a +shared OS image alone. Native execution retains its existing configurable compiler +selection. + +`schema/` contains schemas for records emitted by current tooling. +`terminology.json` defines every normative fact, step, join, and formula identifier. +The generated human view remains in `docs/BENCHMARK_TERMINOLOGY.md`, and the full +workflow is documented in `docs/BENCHMARK_EXPERIMENTS.md`. + +The upstream-owned `scripts/benchmark-index.sh`, `scripts/benchmark-search-graph.sh`, +and `scripts/clone-bench-repos.sh` retain their established locations. Branch-created +Python benchmark implementations live here without executable compatibility copies. +`docs/schema/benchmark-facts-v1.schema.json` retains its frozen URI because v1 bundles +embed that identifier. diff --git a/benchmarks/autotune.py b/benchmarks/autotune.py new file mode 100755 index 000000000..62782f5a9 --- /dev/null +++ b/benchmarks/autotune.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Create or run an auditable PageRank tuning experiment. + +This compatibility frontend uses the repository's versioned rank-quality fixture +and content-addressed experiment runner. It never changes the user's normal CBM +configuration or cache, and it retains every result under an ignored durable +experiment root rather than an operating-system temporary directory. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +BENCHMARK = ROOT / "benchmarks" / "run_benchmark.py" +EXPERIMENT_RUNNER = ROOT / "benchmarks" / "run_experiments.py" +DEFAULT_EXPERIMENT_ROOT = ROOT / ".worktrees" / "benchmark-experiments" / "autotune" + +# Each row is an independently identified experiment profile. The first two are +# the essential capability ablation; the remaining rows preserve the useful +# parameter sweep from the former global-config autotuner. +TUNING_PROFILES: tuple[dict[str, Any], ...] = ( + { + "label": "candidate-default", + "config_profile": "automatic_dependency_source_indexing_disabled", + "capabilities": {"rank_enabled": "candidate_default"}, + }, + { + "label": "rank-disabled", + "config_profile": "rank_disabled", + "capabilities": {"rank_enabled": "false"}, + }, + { + "label": "calls-boost", + "config_profile": "automatic_dependency_source_indexing_disabled", + "capabilities": {"rank_enabled": "true"}, + "config_overrides": {"edge_weight_calls": "2.0", "edge_weight_usage": "0.3"}, + }, + { + "label": "usage-dampen", + "config_profile": "automatic_dependency_source_indexing_disabled", + "capabilities": {"rank_enabled": "true"}, + "config_overrides": {"edge_weight_usage": "0.3", "edge_weight_defines": "0.05"}, + }, + { + "label": "tests-dampen", + "config_profile": "automatic_dependency_source_indexing_disabled", + "capabilities": {"rank_enabled": "true"}, + "config_overrides": {"edge_weight_tests": "0.01", "edge_weight_usage": "0.3"}, + }, + { + "label": "calls-boost-tests-dampen", + "config_profile": "automatic_dependency_source_indexing_disabled", + "capabilities": {"rank_enabled": "true"}, + "config_overrides": { + "edge_weight_calls": "2.0", + "edge_weight_usage": "0.3", + "edge_weight_tests": "0.01", + }, + }, + { + "label": "more-iterations", + "config_profile": "automatic_dependency_source_indexing_disabled", + "capabilities": {"rank_enabled": "true"}, + "config_overrides": {"pagerank_max_iter": "100"}, + }, +) + + +def load_experiment_runner(path: Path = EXPERIMENT_RUNNER) -> ModuleType: + spec = importlib.util.spec_from_file_location("cbm_benchmark_experiment", path) + if not spec or not spec.loader: + raise RuntimeError(f"cannot load experiment runner: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def git_revision(repo: Path) -> str: + proc = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + text=True, + capture_output=True, + check=False, + ) + revision = proc.stdout.strip() + if proc.returncode != 0 or len(revision) != 40: + raise ValueError( + f"cannot resolve a full Git revision for {repo}: {proc.stderr.strip()}" + ) + return revision + + +def build_matrix_spec( + *, + binary: Path, + revision: str, + repetitions: int, + timeout_seconds: int, + transports: list[str], + build: dict[str, str], +) -> dict[str, Any]: + if not binary.is_file(): + raise ValueError(f"binary does not exist: {binary}") + if len(revision) != 40: + raise ValueError("revision must be a full 40-character commit hash") + if repetitions <= 0 or timeout_seconds <= 0: + raise ValueError("repetitions and timeout_seconds must be positive") + if not transports or not set(transports).issubset({"cli", "mcp"}): + raise ValueError("transports must contain cli, mcp, or both") + for key in ("target", "compiler", "cflags"): + if not build.get(key): + raise ValueError(f"build metadata requires non-empty {key}") + + runner = load_experiment_runner() + return { + "schema_version": 1, + "harness_version": f"run_benchmark.py:{runner.file_sha256(BENCHMARK)}", + "benchmark_script": str(BENCHMARK), + "capability_quality": "rank", + "index_mode": "full", + "cwd": str(ROOT), + "timeout_seconds": timeout_seconds, + "cell_timeout_seconds": timeout_seconds * 4, + "accepted_exit_codes": [0, 1], + "execution_order": "paired_interleaved", + "repetitions": repetitions, + "transports": transports, + "candidates": [ + { + "label": "candidate", + "revision": revision, + "binary": str(binary.resolve()), + "build": dict(sorted(build.items())), + "capability_support": {"rank": True}, + } + ], + "profiles": [dict(profile) for profile in TUNING_PROFILES], + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--binary", type=Path, default=ROOT / "build" / "c" / "codebase-memory-mcp" + ) + parser.add_argument( + "--revision", + default="", + help="Full candidate commit; defaults to repository HEAD.", + ) + parser.add_argument( + "--experiment-root", + "--campaign-root", + dest="experiment_root", + type=Path, + default=DEFAULT_EXPERIMENT_ROOT, + help="Durable result root (--campaign-root is a legacy alias).", + ) + parser.add_argument("--repetitions", type=int, default=3) + parser.add_argument("--timeout", type=int, default=1200) + parser.add_argument("--transport", choices=("cli", "mcp", "both"), default="both") + parser.add_argument( + "--build-target", + required=True, + help="Exact build command/target used for the binary.", + ) + parser.add_argument( + "--compiler", required=True, help="Exact compiler identity/version." + ) + parser.add_argument( + "--cflags", required=True, help="Exact optimization/profiling flags." + ) + parser.add_argument( + "--plan-only", + action="store_true", + help="Write and validate the plan without running cells.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + binary = args.binary.expanduser().resolve() + revision = args.revision or git_revision(ROOT) + transports = ["cli", "mcp"] if args.transport == "both" else [args.transport] + build = { + "target": args.build_target, + "compiler": args.compiler, + "cflags": args.cflags, + } + spec = build_matrix_spec( + binary=binary, + revision=revision, + repetitions=args.repetitions, + timeout_seconds=args.timeout, + transports=transports, + build=build, + ) + + runner = load_experiment_runner() + plan = runner.expand_matrix_spec(spec) + experiment_root = args.experiment_root.expanduser().resolve() + runner.validate_experiment_root(experiment_root) + experiment_root.mkdir(parents=True, exist_ok=True) + spec_path = experiment_root / "autotune-matrix-spec.json" + plan_path = experiment_root / "autotune-plan.json" + runner.atomic_write_json(spec_path, spec) + runner.atomic_write_json(plan_path, plan) + if args.plan_only: + print( + json.dumps( + {"matrix_spec": str(spec_path), "plan": str(plan_path)}, indent=2 + ) + ) + return 0 + + os.execv( + sys.executable, + [ + sys.executable, + str(EXPERIMENT_RUNNER), + "--plan", + str(plan_path), + "--experiment-root", + str(experiment_root), + ], + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/config-spellings-v1.json b/benchmarks/config-spellings-v1.json new file mode 100644 index 000000000..a74d13929 --- /dev/null +++ b/benchmarks/config-spellings-v1.json @@ -0,0 +1,109 @@ +{ + "schema_version": 1, + "profiles": { + "derived_results_refresh_at_publish": { + "canonical": "incremental_derived_results_refresh_at_publish", + "historical": [ + "incremental_semantic_freshness_eager" + ] + } + }, + "experiment_labels": { + "derived_results_refresh_at_publish": { + "canonical": "derived-results-refresh-at-publish", + "historical": [ + "eager-derived-freshness" + ] + } + }, + "config_overrides": [ + { + "id": "incremental_reindex_full_rebuild", + "canonical": { + "key": "incremental_reindex", + "value": "full_rebuild" + }, + "historical": { + "key": "incremental_reindex", + "value": "off" + } + }, + { + "id": "incremental_reindex_fast_mode_indexes_only", + "canonical": { + "key": "incremental_reindex", + "value": "fast_mode_indexes_only" + }, + "historical": { + "key": "incremental_reindex", + "value": "fast" + } + }, + { + "id": "incremental_derived_results_refresh_at_publish", + "canonical": { + "key": "incremental_derived_results_refresh", + "value": "at_publish" + }, + "historical": { + "key": "incremental_derived_refresh", + "value": "eager" + } + }, + { + "id": "incremental_derived_results_refresh_defer_exact_delta_reindexes", + "canonical": { + "key": "incremental_derived_results_refresh", + "value": "defer_exact_delta_reindexes" + }, + "historical": { + "key": "incremental_derived_refresh", + "value": "stale_on_exact" + } + }, + { + "id": "incremental_derived_results_refresh_defer_all_incremental_reindexes", + "canonical": { + "key": "incremental_derived_results_refresh", + "value": "defer_all_incremental_reindexes" + }, + "historical": { + "key": "incremental_derived_refresh", + "value": "stale_on_incremental" + } + }, + { + "id": "rank_refresh_at_publish", + "canonical": { + "key": "rank_refresh", + "value": "at_publish" + }, + "historical": { + "key": "rank_refresh", + "value": "eager" + } + }, + { + "id": "rank_refresh_defer_exact_delta_reindexes", + "canonical": { + "key": "rank_refresh", + "value": "defer_exact_delta_reindexes" + }, + "historical": { + "key": "rank_refresh", + "value": "stale_on_exact" + } + }, + { + "id": "rank_refresh_defer_all_incremental_reindexes", + "canonical": { + "key": "rank_refresh", + "value": "defer_all_incremental_reindexes" + }, + "historical": { + "key": "rank_refresh", + "value": "stale_on_incremental" + } + } + ] +} diff --git a/benchmarks/environment-policy-v1.json b/benchmarks/environment-policy-v1.json new file mode 100644 index 000000000..b205f3ab2 --- /dev/null +++ b/benchmarks/environment-policy-v1.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "product_environment_prefix": "CBM_", + "build_environment_keys": [ + "CC", + "CXX", + "EXTRA_CFLAGS", + "EXTRA_CXXFLAGS" + ], + "harness_owned_keys": [ + "CBM_AUTO_INDEX", + "CBM_BENCHMARK_ARTIFACT_DIR", + "CBM_BENCHMARK_RUN_CONTEXT", + "CBM_CACHE_DIR", + "CBM_CONTEXT_INJECTION", + "CBM_PROFILE" + ] +} diff --git a/benchmarks/fact_comparisons.py b/benchmarks/fact_comparisons.py new file mode 100755 index 000000000..987bb1109 --- /dev/null +++ b/benchmarks/fact_comparisons.py @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +"""Derive auditable comparison and lifecycle views from benchmark fact bundles.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path, PureWindowsPath +import statistics +import sys +import tempfile +from typing import Any, Iterable + + +SCHEMA_VERSION = 1 +SCHEMA_URI = "benchmarks/schema/comparisons-v1.schema.json" +FACT_SCHEMA_URIS = { + 1: {"docs/schema/benchmark-facts-v1.schema.json"}, + 2: { + "benchmarks/schema/facts-v2.schema.json", + "docs/schema/benchmark-facts-v2.schema.json", + }, +} +PARITY_JOIN_ID = "parity_manifest_and_contract_v1" +CAPABILITY_DELTA_JOIN_ID = "capability_delta_manifest_v1" +MEDIAN_FORMULA_ID = "median_elapsed_ms_v1" +RATIO_FORMULA_ID = "left_elapsed_divided_by_right_elapsed_v1" +LIFECYCLE_STEP_IDS = ( + "initial_index", + "incremental_index", + "clean_rebuild_index", +) +REPO_ROOT = Path(__file__).resolve().parents[1] +TERMINOLOGY_PATH = Path(__file__).with_name("terminology.json") + + +def canonical_json_bytes(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def content_id(value: Any, length: int = 24) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest()[:length] + + +def portable_source_path(path: Path) -> str: + resolved = path.resolve() + try: + return str(resolved.relative_to(REPO_ROOT)) + except ValueError: + parent_id = content_id(str(resolved.parent), length=12) + return f"external/{parent_id}/{resolved.name}" + + +def portable_value(value: Any) -> Any: + if isinstance(value, dict): + return {key: portable_value(child) for key, child in value.items()} + if isinstance(value, list): + return [portable_value(child) for child in value] + if isinstance(value, str): + if Path(value).is_absolute(): + return portable_source_path(Path(value)) + windows_path = PureWindowsPath(value) + if windows_path.is_absolute(): + parent_id = content_id(str(windows_path.parent), length=12) + return f"external/{parent_id}/{windows_path.name}" + return value + + +def is_unknown(value: Any) -> bool: + if isinstance(value, dict): + if value.get("status") == "unknown": + return True + return any(is_unknown(child) for child in value.values()) + if isinstance(value, list): + return any(is_unknown(child) for child in value) + return False + + +def load_fact_bundle(path: Path) -> dict[str, Any]: + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError(f"fact bundle must be an object: {path}") + version = document.get("schema_version") + expected_uris = FACT_SCHEMA_URIS.get(version) + if expected_uris is None or document.get("$schema") not in expected_uris: + raise ValueError(f"unsupported or mismatched fact schema in {path}") + for table in ("runs", "steps", "results", "artifacts"): + if not isinstance(document.get(table), list): + raise ValueError(f"fact bundle {table} must be an array: {path}") + if len(document["runs"]) != 1 or not isinstance(document["runs"][0], dict): + raise ValueError(f"fact bundle must contain one run row: {path}") + run_id = document["runs"][0].get("run_id") + if not isinstance(run_id, str): + raise ValueError(f"fact bundle run_id is invalid: {path}") + for field, expected_type in ( + ("cell_label", str), + ("mode", str), + ("implementation", dict), + ("capabilities", dict), + ("scope", dict), + ("cache", dict), + ("host", dict), + ("harness", dict), + ): + if not isinstance(document["runs"][0].get(field), expected_type): + raise ValueError(f"fact bundle run {field} is invalid: {path}") + if version == 2: + for field in ( + "terminology_version", + "terminology_sha256", + "generator_revision", + ): + if not isinstance(document.get(field), str): + raise ValueError(f"fact bundle {field} is invalid: {path}") + for table in ("steps", "results", "artifacts"): + if any( + not isinstance(row, dict) or row.get("run_id") != run_id + for row in document[table] + ): + raise ValueError(f"fact bundle {table} has a foreign run_id: {path}") + return document + + +def result_contract_projection( + results: Iterable[dict[str, Any]], +) -> list[dict[str, Any]]: + projection = [] + for row in results: + value = row.get("value") + contract: Any = None + if isinstance(value, dict): + contract = { + key: value[key] + for key in ( + "scenario", + "criterion", + "expected_substring", + "applicable", + "policy", + "declared_stale_views", + "excluded_edge_types", + ) + if key in value + } + projection.append( + { + "result_id": row.get("result_id"), + "kind": row.get("kind"), + "contract": contract, + } + ) + return sorted(projection, key=lambda row: (str(row["result_id"]), str(row["kind"]))) + + +def implementation_projection(implementation: Any) -> dict[str, Any]: + if not isinstance(implementation, dict): + return {} + binary = implementation.get("binary") + return { + "revision": implementation.get("revision"), + "binary": ( + {key: binary.get(key) for key in ("sha256", "size_bytes") if key in binary} + if isinstance(binary, dict) + else {} + ), + "build": implementation.get("build"), + } + + +def capability_projection(capabilities: Any) -> dict[str, Any]: + if not isinstance(capabilities, dict): + return {} + return { + key: capabilities.get(key) + for key in ("values", "completeness") + if key in capabilities + } + + +def harness_projection(harness: Any) -> dict[str, Any]: + if not isinstance(harness, dict): + return {} + return { + key: harness.get(key) + for key in ("fact_schema_version", "sha256") + if key in harness + } + + +def benchmark_contract_projection( + bundle: dict[str, Any], run: dict[str, Any] +) -> dict[str, Any]: + return { + "fact_schema_version": bundle.get("schema_version"), + "terminology_version": bundle.get( + "terminology_version", + {"status": "unknown", "reason": "fact_schema_v1_did_not_record_it"}, + ), + "terminology_sha256": bundle.get( + "terminology_sha256", + {"status": "unknown", "reason": "fact_schema_v1_did_not_record_it"}, + ), + "generator_revision": bundle.get( + "generator_revision", + {"status": "unknown", "reason": "fact_schema_v1_did_not_record_it"}, + ), + "harness": harness_projection(run.get("harness")), + } + + +def cell_group_identity( + bundle: dict[str, Any], run: dict[str, Any], results: list[dict[str, Any]] +) -> dict[str, Any]: + return { + "label": run.get("cell_label"), + "mode": run.get("mode"), + "implementation": implementation_projection(run.get("implementation")), + "capabilities": capability_projection(run.get("capabilities")), + "scope": run.get("scope"), + "cache": run.get("cache"), + "host": run.get("host"), + "benchmark_contract": benchmark_contract_projection(bundle, run), + "result_contract": result_contract_projection(results), + } + + +def aggregate_steps(step_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = {} + for row in step_rows: + step_id = row.get("step_id") + elapsed = row.get("elapsed_ms") + if ( + isinstance(step_id, str) + and isinstance(elapsed, (int, float)) + and not isinstance(elapsed, bool) + ): + grouped.setdefault(step_id, []).append(row) + aggregates = [] + for step_id, rows in sorted(grouped.items()): + values = [float(row["elapsed_ms"]) for row in rows] + aggregates.append( + { + "step_id": step_id, + "formula_id": MEDIAN_FORMULA_ID, + "count": len(values), + "median_elapsed_ms": statistics.median(values), + "min_elapsed_ms": min(values), + "max_elapsed_ms": max(values), + "source_occurrence_ids": sorted( + str(row["occurrence_id"]) for row in rows + ), + } + ) + return aggregates + + +def aggregate_results(result_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + for row in result_rows: + result_id = row.get("result_id") + kind = row.get("kind") + if isinstance(result_id, str) and isinstance(kind, str): + grouped.setdefault((result_id, kind), []).append(row) + aggregates = [] + for (result_id, kind), rows in sorted(grouped.items()): + statuses = [str(row.get("status")) for row in rows] + aggregates.append( + { + "result_id": result_id, + "kind": kind, + "statuses": statuses, + "all_passed": bool(statuses) + and all(status == "passed" for status in statuses), + "source_run_ids": sorted(str(row["run_id"]) for row in rows), + } + ) + return aggregates + + +def aggregate_cells( + sources: list[tuple[Path, dict[str, Any]]], +) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + seen_run_ids: dict[str, Path] = {} + for path, bundle in sources: + run = bundle["runs"][0] + run_id = run["run_id"] + previous_path = seen_run_ids.get(run_id) + if previous_path is not None: + raise ValueError( + f"duplicate fact run_id {run_id}: {previous_path} and {path}" + ) + seen_run_ids[run_id] = path + identity = cell_group_identity(bundle, run, bundle["results"]) + group_id = content_id(identity) + group = groups.setdefault( + group_id, + { + "cell_group_id": group_id, + "label": run.get("cell_label"), + "mode": run.get("mode"), + "implementation": identity["implementation"], + "capabilities": identity["capabilities"], + "scope": run.get("scope"), + "cache": run.get("cache"), + "host": run.get("host"), + "benchmark_contract": identity["benchmark_contract"], + "result_contract": identity["result_contract"], + "source_run_ids": [], + "source_fact_paths": [], + "_steps": [], + "_results": [], + }, + ) + group["source_run_ids"].append(run["run_id"]) + group["source_fact_paths"].append(portable_source_path(path)) + group["_steps"].extend(bundle["steps"]) + group["_results"].extend(bundle["results"]) + cells = [] + for group in groups.values(): + group["source_run_ids"].sort() + group["source_fact_paths"].sort() + group["step_aggregates"] = aggregate_steps(group.pop("_steps")) + group["result_aggregates"] = aggregate_results(group.pop("_results")) + cells.append(group) + return sorted( + cells, + key=lambda cell: ( + str(cell.get("label")), + str(cell.get("implementation", {}).get("revision")), + cell["cell_group_id"], + ), + ) + + +def capability_differences(left: Any, right: Any) -> list[dict[str, Any]]: + left_values = left.get("values", {}) if isinstance(left, dict) else {} + right_values = right.get("values", {}) if isinstance(right, dict) else {} + if not isinstance(left_values, dict) or not isinstance(right_values, dict): + return [] + return [ + { + "capability_id": key, + "left": left_values.get(key), + "right": right_values.get(key), + } + for key in sorted(set(left_values) | set(right_values)) + if left_values.get(key) != right_values.get(key) + ] + + +def manifest_equal(left: dict[str, Any], right: dict[str, Any], key: str) -> bool: + return canonical_json_bytes(left.get(key)) == canonical_json_bytes(right.get(key)) + + +def capabilities_complete(cell: dict[str, Any]) -> bool: + capabilities = cell.get("capabilities") + return ( + isinstance(capabilities, dict) + and capabilities.get("completeness") == "complete_declared_cell" + and not is_unknown(capabilities) + ) + + +def common_step_ratios( + left: dict[str, Any], right: dict[str, Any] +) -> list[dict[str, Any]]: + left_steps = {row["step_id"]: row for row in left["step_aggregates"]} + right_steps = {row["step_id"]: row for row in right["step_aggregates"]} + rows = [] + for step_id in sorted(set(left_steps) & set(right_steps)): + numerator = left_steps[step_id]["median_elapsed_ms"] + denominator = right_steps[step_id]["median_elapsed_ms"] + rows.append( + { + "step_id": step_id, + "formula_id": RATIO_FORMULA_ID, + "left_median_elapsed_ms": numerator, + "right_median_elapsed_ms": denominator, + "left_elapsed_divided_by_right_elapsed": ( + numerator / denominator if denominator > 0 else None + ), + "left_source_occurrence_ids": left_steps[step_id][ + "source_occurrence_ids" + ], + "right_source_occurrence_ids": right_steps[step_id][ + "source_occurrence_ids" + ], + } + ) + return rows + + +def classify_pair(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: + same_mode = left.get("mode") == right.get("mode") + same_scope = manifest_equal(left, right, "scope") + same_cache = manifest_equal(left, right, "cache") + same_host = manifest_equal(left, right, "host") + same_benchmark_contract = manifest_equal(left, right, "benchmark_contract") + same_contract = manifest_equal(left, right, "result_contract") + same_capabilities = manifest_equal(left, right, "capabilities") + complete = capabilities_complete(left) and capabilities_complete(right) + manifest_unknown = any( + is_unknown(cell.get(key)) + for cell in (left, right) + for key in ( + "scope", + "cache", + "host", + "benchmark_contract", + "result_contract", + ) + ) + common = { + "comparison_id": content_id( + { + "left": left["cell_group_id"], + "right": right["cell_group_id"], + } + ), + "left_cell_group_id": left["cell_group_id"], + "right_cell_group_id": right["cell_group_id"], + "left_source_run_ids": left["source_run_ids"], + "right_source_run_ids": right["source_run_ids"], + } + if ( + same_mode + and same_scope + and same_cache + and same_host + and same_benchmark_contract + and same_contract + and same_capabilities + and complete + and not manifest_unknown + ): + return { + **common, + "comparison_kind": "parity_comparison", + "join_id": PARITY_JOIN_ID, + "ratio_allowed": True, + "capability_differences": [], + "step_comparisons": common_step_ratios(left, right), + "limitations": [], + } + differences = capability_differences( + left.get("capabilities"), right.get("capabilities") + ) + if ( + same_mode + and same_scope + and same_cache + and same_host + and same_benchmark_contract + and same_contract + and complete + and differences + ): + limitations = [] + if manifest_unknown: + limitations.append( + "scope, cache, host, benchmark-contract, or correctness metadata " + "contains unknown values" + ) + return { + **common, + "comparison_kind": "capability_delta_comparison", + "join_id": CAPABILITY_DELTA_JOIN_ID, + "ratio_allowed": False, + "capability_differences": differences, + "step_comparisons": [], + "limitations": limitations, + } + reasons = [] + for matched, reason in ( + (same_mode, "workload mode differs"), + (same_scope, "scope manifest differs"), + (same_cache, "cache manifest differs"), + (same_host, "host manifest differs"), + (same_benchmark_contract, "benchmark contract differs"), + (same_contract, "correctness contract differs"), + (complete, "capability manifest is incomplete or unknown"), + ): + if not matched: + reasons.append(reason) + if manifest_unknown: + reasons.append( + "required manifest or benchmark contract contains unknown values" + ) + return { + **common, + "comparison_kind": "not_eligible", + "join_id": None, + "ratio_allowed": False, + "capability_differences": differences, + "step_comparisons": [], + "limitations": sorted(set(reasons)), + } + + +def generate_comparison_document( + sources: list[tuple[Path, dict[str, Any]]], + *, + generated_at_utc: str, + terminology_version: str, + terminology_sha256: str, +) -> dict[str, Any]: + cells = aggregate_cells(sources) + comparisons = [ + classify_pair(cells[left], cells[right]) + for left in range(len(cells)) + for right in range(left + 1, len(cells)) + ] + source_bundles = [ + { + "path": portable_source_path(path), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "schema_version": bundle["schema_version"], + "run_id": bundle["runs"][0]["run_id"], + } + for path, bundle in sorted(sources, key=lambda item: str(item[0])) + ] + return { + "$schema": SCHEMA_URI, + "schema_version": SCHEMA_VERSION, + "generated_at_utc": generated_at_utc, + "terminology_version": terminology_version, + "terminology_sha256": terminology_sha256, + "joins": [ + { + "join_id": PARITY_JOIN_ID, + "fields": [ + "mode", + "capabilities", + "scope", + "cache", + "host", + "benchmark_contract", + "result_contract", + ], + "unknown_values_allowed": False, + "ratio_allowed": True, + }, + { + "join_id": CAPABILITY_DELTA_JOIN_ID, + "fields": [ + "mode", + "scope", + "cache", + "host", + "benchmark_contract", + "result_contract", + ], + "unknown_values_allowed": True, + "ratio_allowed": False, + }, + ], + "formulas": [ + { + "formula_id": MEDIAN_FORMULA_ID, + "expression": "versioned median of elapsed_ms for one cell group and step_id", + }, + { + "formula_id": RATIO_FORMULA_ID, + "expression": "left median elapsed_ms / right median elapsed_ms", + }, + ], + "source_bundles": source_bundles, + "cell_groups": portable_value(cells), + "comparisons": comparisons, + "lifecycle_rows": [ + { + "cell_group_id": cell["cell_group_id"], + "label": cell["label"], + "source_run_ids": cell["source_run_ids"], + "steps": [ + step + for step in cell["step_aggregates"] + if step["step_id"] in LIFECYCLE_STEP_IDS + ], + "wall_time_rule": ( + "each listed lifecycle step uses its recorded outer elapsed_ms; " + "component spans are not summed" + ), + } + for cell in cells + ], + } + + +def render_markdown(document: dict[str, Any]) -> str: + comparisons = document["comparisons"] + parity = [ + row for row in comparisons if row["comparison_kind"] == "parity_comparison" + ] + deltas = [ + row + for row in comparisons + if row["comparison_kind"] == "capability_delta_comparison" + ] + rejected = [row for row in comparisons if row["comparison_kind"] == "not_eligible"] + lines = [ + "## Fact-derived comparison audit", + "", + f"- Source fact bundles: {len(document['source_bundles'])}", + f"- Cell groups: {len(document['cell_groups'])}", + f"- Apples-to-apples parity pairs: {len(parity)}", + f"- Apples-to-oranges capability-delta pairs: {len(deltas)}", + f"- Ineligible pairs: {len(rejected)}", + "", + "Ratios appear only for parity pairs. Capability-delta rows deliberately carry no " + "cross-implementation speed ratio. Component spans remain separate when their " + "recorded boundaries cannot prove serial execution.", + "", + "### Lifecycle fact table", + "", + "| Configuration | Step | Median ms | Repetitions | Source occurrence IDs |", + "|---|---|---:|---:|---|", + ] + for lifecycle in document["lifecycle_rows"]: + lines.extend( + ( + f"| {lifecycle['label']} | `{step['step_id']}` | " + f"{step['median_elapsed_ms']:.3f} | {step['count']} | " + f"`{', '.join(step['source_occurrence_ids'])}` |" + ) + for step in lifecycle["steps"] + ) + lines.extend( + ( + "", + "The adjacent comparison JSON retains every join ID, formula ID, source run ID, " + "result contract, step occurrence ID, capability manifest, scope manifest, and " + "cache manifest used to classify these rows.", + "", + ) + ) + return "\n".join(lines) + + +def atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--fact", + action="append", + type=Path, + required=True, + help="Fact bundle to include; repeat for every completed run.", + ) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--markdown-out", type=Path, required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + sources = [(path, load_fact_bundle(path)) for path in args.fact] + terminology = json.loads(TERMINOLOGY_PATH.read_text(encoding="utf-8")) + document = generate_comparison_document( + sources, + generated_at_utc=datetime.now(timezone.utc).isoformat(), + terminology_version=terminology["terminology_version"], + terminology_sha256=hashlib.sha256( + canonical_json_bytes(terminology) + ).hexdigest(), + ) + atomic_write_text( + args.out, json.dumps(document, indent=2, sort_keys=True) + "\n" + ) + atomic_write_text(args.markdown_out, render_markdown(document)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"error: cannot generate fact comparisons: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/generate_terminology.py b/benchmarks/generate_terminology.py new file mode 100755 index 000000000..813a7e75e --- /dev/null +++ b/benchmarks/generate_terminology.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Validate benchmark terminology and render its checked-in derived views.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import sys +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +REGISTRY_PATH = Path(__file__).with_name("terminology.json") +MARKDOWN_PATH = REPO_ROOT / "docs" / "BENCHMARK_TERMINOLOGY.md" +HEADER_PATH = REPO_ROOT / "src" / "foundation" / "profile_terms_generated.h" +REQUIRED_ENTRY_FIELDS = ( + "term_id", + "display_name", + "definition", + "status", + "kind", + "data_type", + "allowed_values_or_range", + "unit", + "clock_or_cpu_scope", + "boundary_semantics", + "aggregation_rule", + "concurrency_rule", + "missing_or_unsupported_behavior", + "configuration_precedence", + "capability_or_freshness_implications", + "source_anchors", + "introduced_version", + "deprecated_replacement", + "examples", +) +TERM_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") + + +def canonical_json_bytes(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def load_registry(path: Path = REGISTRY_PATH) -> dict[str, Any]: + with path.open(encoding="utf-8") as stream: + registry = json.load(stream) + validate_registry(registry) + return registry + + +def validate_registry(registry: dict[str, Any]) -> None: + if registry.get("schema_version") != 1: + raise ValueError("benchmark terminology schema_version must equal 1") + version = registry.get("terminology_version") + if not isinstance(version, str) or not re.fullmatch(r"[1-9]\d*\.\d+\.\d+", version): + raise ValueError("terminology_version must be a semantic version") + entries = registry.get("entries") + if not isinstance(entries, list) or not entries: + raise ValueError("benchmark terminology entries must be a non-empty array") + seen: set[str] = set() + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ValueError(f"terminology entry {index} must be an object") + missing = [field for field in REQUIRED_ENTRY_FIELDS if field not in entry] + if missing: + raise ValueError( + f"terminology entry {index} is missing fields: {', '.join(missing)}" + ) + term_id = entry["term_id"] + if not isinstance(term_id, str) or not TERM_ID_PATTERN.fullmatch(term_id): + raise ValueError(f"invalid term_id at entry {index}: {term_id!r}") + if term_id in seen: + raise ValueError(f"duplicate benchmark term_id: {term_id}") + seen.add(term_id) + if entry["status"] not in {"existing", "proposed", "deprecated"}: + raise ValueError(f"{term_id}: invalid status {entry['status']!r}") + for field in ( + "display_name", + "definition", + "kind", + "data_type", + "allowed_values_or_range", + "unit", + "clock_or_cpu_scope", + "boundary_semantics", + "aggregation_rule", + "concurrency_rule", + "missing_or_unsupported_behavior", + "configuration_precedence", + "capability_or_freshness_implications", + "introduced_version", + ): + if not isinstance(entry[field], str) or not entry[field].strip(): + raise ValueError(f"{term_id}: {field} must be a non-empty string") + if not isinstance(entry["source_anchors"], list) or not entry["source_anchors"]: + raise ValueError(f"{term_id}: source_anchors must be a non-empty array") + if not all( + isinstance(anchor, str) and anchor.strip() + for anchor in entry["source_anchors"] + ): + raise ValueError(f"{term_id}: source_anchors contains an invalid anchor") + if not isinstance(entry["examples"], list): + raise ValueError(f"{term_id}: examples must be an array") + replacement = entry["deprecated_replacement"] + if replacement is not None and ( + not isinstance(replacement, str) + or not TERM_ID_PATTERN.fullmatch(replacement) + ): + raise ValueError(f"{term_id}: deprecated_replacement is invalid") + if entry["status"] == "deprecated" and replacement is None: + raise ValueError(f"{term_id}: deprecated term requires a replacement") + for entry in entries: + replacement = entry["deprecated_replacement"] + if replacement is not None and replacement not in seen: + raise ValueError( + f"{entry['term_id']}: replacement {replacement!r} is not registered" + ) + step_id_order = registry.get("step_id_order") + if not isinstance(step_id_order, list) or not step_id_order: + raise ValueError("step_id_order must be a non-empty array") + if len(step_id_order) != len(set(step_id_order)): + raise ValueError("step_id_order contains a duplicate") + registered_step_ids = { + entry["term_id"] for entry in entries if entry["kind"] == "step_id" + } + if set(step_id_order) != registered_step_ids: + raise ValueError( + "step_id_order must contain every registered step_id exactly once" + ) + + +def registry_sha256(registry: dict[str, Any]) -> str: + return hashlib.sha256(canonical_json_bytes(registry)).hexdigest() + + +def markdown_cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") + + +def render_markdown(registry: dict[str, Any]) -> str: + digest = registry_sha256(registry) + lines = [ + "# Benchmark terminology", + "", + "", + "", + f"- Terminology version: `{registry['terminology_version']}`", + "- Canonical registry: `benchmarks/terminology.json`", + f"- Canonical-content SHA-256: `{digest}`", + "", + "Every definition below is normative. Parent relations describe containment, " + "not execution order; overlapping elapsed spans are work-time evidence and must " + "not be summed into lifecycle wall time.", + "", + ] + by_kind: dict[str, list[dict[str, Any]]] = {} + for entry in registry["entries"]: + by_kind.setdefault(entry["kind"], []).append(entry) + for kind in sorted(by_kind): + lines.extend((f"## {kind.replace('_', ' ').title()}", "")) + lines.extend( + ( + "| ID | Normative definition | Status; type; unit | " + "Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources |", + "|---|---|---|---|---|---|", + ) + ) + for entry in sorted(by_kind[kind], key=lambda item: item["term_id"]): + identity = f"`{entry['term_id']}`
{entry['display_name']}" + type_cell = ( + f"{entry['status']}; {entry['data_type']}; " + f"{entry['allowed_values_or_range']}; {entry['unit']}; " + f"scope: {entry['clock_or_cpu_scope']}" + ) + timing_cell = ( + f"boundaries: {entry['boundary_semantics']}; " + f"aggregation: {entry['aggregation_rule']}; " + f"concurrency: {entry['concurrency_rule']}" + ) + behavior_cell = ( + f"missing/unsupported: {entry['missing_or_unsupported_behavior']}; " + f"configuration: {entry['configuration_precedence']}; " + f"effect: {entry['capability_or_freshness_implications']}" + ) + sources = ", ".join(f"`{anchor}`" for anchor in entry["source_anchors"]) + lines.append( + "| " + + " | ".join( + markdown_cell(value) + for value in ( + identity, + entry["definition"], + type_cell, + timing_cell, + behavior_cell, + sources, + ) + ) + + " |" + ) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def render_header(registry: dict[str, Any]) -> str: + step_ids = registry["step_id_order"] + macro_lines = ["#define CBM_BENCHMARK_STEP_IDS(X)"] + macro_lines.extend( + f' X({term_id.upper()}, "{term_id}")' for term_id in step_ids + ) + # Emit the repository's clang-format-stable escaped-newline layout so the + # generator check and format gate remain composable on every platform. + continuation_width = max(len(line) for line in macro_lines[:-1]) + rows = " \\\n".join( + line.ljust(continuation_width) for line in macro_lines[:-1] + ) + rows += f" \\\n{macro_lines[-1]}" + return ( + "/* Generated by benchmarks/generate_terminology.py; do not edit. */\n" + "#ifndef CBM_PROFILE_TERMS_GENERATED_H\n" + "#define CBM_PROFILE_TERMS_GENERATED_H\n\n" + f'#define CBM_BENCHMARK_TERMINOLOGY_VERSION "{registry["terminology_version"]}"\n' + "#define CBM_BENCHMARK_TERMINOLOGY_SHA256 \\\n" + f' "{registry_sha256(registry)}"\n\n' + f"{rows}\n\n" + "#endif /* CBM_PROFILE_TERMS_GENERATED_H */\n" + ) + + +def check_or_write(path: Path, expected: str, check: bool) -> bool: + actual = path.read_text(encoding="utf-8") if path.exists() else None + if actual == expected: + return True + if check: + print(f"stale generated benchmark terminology file: {path}", file=sys.stderr) + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(expected, encoding="utf-8") + return True + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Fail if generated Markdown or C step-ID definitions are stale.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + registry = load_registry() + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"invalid benchmark terminology registry: {exc}", file=sys.stderr) + return 1 + ok = check_or_write(MARKDOWN_PATH, render_markdown(registry), args.check) + ok = check_or_write(HEADER_PATH, render_header(registry), args.check) and ok + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/run_benchmark.py b/benchmarks/run_benchmark.py new file mode 100755 index 000000000..42d2f66a1 --- /dev/null +++ b/benchmarks/run_benchmark.py @@ -0,0 +1,7694 @@ +#!/usr/bin/env python3 +"""Run one isolated indexing benchmark and emit auditable fact tables. + +The default workload gates fast-mode exact incremental indexing against a fresh +full rebuild. Additional flags select self-dogfood, quality, scaling, and surface +measurements. Every workload uses an isolated cache and removes only paths it created. +""" + +from __future__ import annotations + +import argparse +from contextlib import closing, suppress +import gzip +import hashlib +from itertools import pairwise +import json +import math +import os +import platform +import queue +import re +import shutil +import sqlite3 +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +CONFIG_SPELLING_SPEC_PATH = Path(__file__).with_name("config-spellings-v1.json") +with CONFIG_SPELLING_SPEC_PATH.open(encoding="utf-8") as stream: + CONFIG_SPELLING_SPEC = json.load(stream) +if CONFIG_SPELLING_SPEC.get("schema_version") != 1: + raise RuntimeError( + f"unsupported benchmark config spelling schema: {CONFIG_SPELLING_SPEC_PATH}" + ) + +BENCHMARK_TERMINOLOGY_PATH = Path(__file__).with_name("terminology.json") +with BENCHMARK_TERMINOLOGY_PATH.open(encoding="utf-8") as stream: + BENCHMARK_TERMINOLOGY = json.load(stream) +if BENCHMARK_TERMINOLOGY.get("schema_version") != 1: + raise RuntimeError( + f"unsupported benchmark terminology schema: {BENCHMARK_TERMINOLOGY_PATH}" + ) +BENCHMARK_TERMINOLOGY_VERSION = BENCHMARK_TERMINOLOGY["terminology_version"] +BENCHMARK_TERMINOLOGY_SHA256 = hashlib.sha256( + json.dumps(BENCHMARK_TERMINOLOGY, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) +).hexdigest() +BENCHMARK_TERMINOLOGY_MARKDOWN_PATH = ( + Path(__file__).resolve().parents[1] / "docs" / "BENCHMARK_TERMINOLOGY.md" +) + + +BENCHMARK_ARTIFACT_DIR_ENV = "CBM_BENCHMARK_ARTIFACT_DIR" +BENCHMARK_RUN_CONTEXT_ENV = "CBM_BENCHMARK_RUN_CONTEXT" +BENCHMARK_ENVIRONMENT_POLICY_PATH = Path(__file__).with_name( + "environment-policy-v1.json" +) +with BENCHMARK_ENVIRONMENT_POLICY_PATH.open(encoding="utf-8") as stream: + BENCHMARK_ENVIRONMENT_POLICY = json.load(stream) +if BENCHMARK_ENVIRONMENT_POLICY.get("schema_version") != 1: + raise RuntimeError( + f"unsupported benchmark environment policy: {BENCHMARK_ENVIRONMENT_POLICY_PATH}" + ) +PRODUCT_ENVIRONMENT_PREFIX = BENCHMARK_ENVIRONMENT_POLICY["product_environment_prefix"] +HARNESS_OWNED_PRODUCT_ENV = frozenset( + BENCHMARK_ENVIRONMENT_POLICY["harness_owned_keys"] +) +DAEMON_LOG_RELATIVE_PATH = Path("logs") / "cbm-daemon.log" +BENCHMARK_FACT_SCHEMA_VERSION = 2 +BENCHMARK_FACT_SCHEMA = "benchmarks/schema/facts-v2.schema.json" +BENCHMARK_FACT_COMPATIBLE_SCHEMA_URIS = { + BENCHMARK_FACT_SCHEMA, + "docs/schema/benchmark-facts-v2.schema.json", +} +BENCHMARK_FACT_LEGACY_SCHEMAS = { + 1: "docs/schema/benchmark-facts-v1.schema.json", +} +REPEATED_JSON_TRIALS = 3 + + +DEFAULT_FILE_COUNT = 240 +DEFAULT_FUNCTIONS_PER_FILE = 12 +DEFAULT_CHANGED_FILES = 2 +DEFAULT_MIN_SPEEDUP = 10.0 +DEFAULT_TIMEOUT_SECONDS = 240 +RANK_REFRESH_CANDIDATE_DEFAULT = "candidate_default" +DEFAULT_RANK_REFRESH = RANK_REFRESH_CANDIDATE_DEFAULT +DEFAULT_OVERHEAD_PROBES = 0 +DEFAULT_OVERHEAD_TOOL = "index_status" +DEFAULT_INDEXED_QUERY_PROBES = 0 +DEFAULT_INDEXED_QUERY_TOOL = "index_status" +DEFAULT_FRONTIER_FILES = 16 +DEFAULT_LIST_PROJECT_COUNTS = "1,16,64" +DEFAULT_LIST_PROJECT_FIXTURE_MAX_MB = 512 +LIST_PROJECT_DISK_RESERVE_BYTES = 2 * 1024 * 1024 * 1024 +LIST_PROJECT_DISK_RESERVE_FRACTION = 0.05 +SEARCH_PROJECTION_INTERNAL_FIELDS = frozenset({"fp", "sp", "bt"}) +SEARCH_PROJECTION_CORE_FIELDS = frozenset( + { + "name", + "qualified_name", + "label", + "file_path", + "pagerank", + "in_degree", + "out_degree", + "source", + "package", + "read_only", + "connected", + } +) +DEFAULT_FASTAPI_URL = "https://github.com/fastapi/fastapi.git" +CONFIG_PROFILE_CANDIDATE_NATIVE = "candidate_native_configuration" +CONFIG_PROFILE_AUTOMATIC_DEPENDENCY_SOURCE_INDEXING_DISABLED = ( + "automatic_dependency_source_indexing_disabled" +) +CONFIG_PROFILE_AUTOMATIC_DEPENDENCY_SOURCE_INDEXING_ENABLED = ( + "automatic_dependency_source_indexing_enabled" +) +CONFIG_PROFILE_DEFAULT = CONFIG_PROFILE_AUTOMATIC_DEPENDENCY_SOURCE_INDEXING_DISABLED +CONFIG_PROFILE_RANK_DISABLED = "rank_disabled" +CONFIG_PROFILE_SIMILARITY_DISABLED = "similarity_disabled" +CONFIG_PROFILE_SEMANTIC_EDGES_DISABLED = "semantic_edges_disabled" +CONFIG_PROFILE_GIT_HISTORY_DISABLED = "git_history_disabled" +CONFIG_PROFILE_HTTP_LINKS_DISABLED = "http_links_disabled" +# Retained plans may still name this removed duplicate profile. +CONFIG_PROFILE_OPTIONAL_GRAPH_DISABLED = "optional_graph_disabled" +CONFIG_PROFILE_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH = CONFIG_SPELLING_SPEC[ + "profiles" +]["derived_results_refresh_at_publish"]["canonical"] +CONFIG_PROFILE_MINIMAL_INDEXING = "minimal_indexing" +DERIVED_REFRESH_CANDIDATE_DEFAULT = "candidate_default" +CONFIG_SPELLING_CANONICAL = "canonical" +CONFIG_SPELLING_PRE_RENAME = "pre_rename" +CONFIG_SPELLING_MODES: dict[tuple[str, int, int], str] = {} +CONFIG_SPELLING_MODES_LOCK = threading.Lock() +CONFIG_OVERRIDE_SPELLINGS = { + entry["id"]: entry for entry in CONFIG_SPELLING_SPEC["config_overrides"] +} +PRE_RENAME_CONFIG_SPELLINGS = { + (entry["canonical"]["key"], entry["canonical"]["value"]): ( + entry["historical"]["key"], + entry["historical"]["value"], + ) + for entry in CONFIG_SPELLING_SPEC["config_overrides"] +} +DERIVED_RESULTS_AT_PUBLISH_OVERRIDE = CONFIG_OVERRIDE_SPELLINGS[ + "incremental_derived_results_refresh_at_publish" +]["canonical"] +RANK_REFRESH_DEFAULT_SPELLINGS = CONFIG_OVERRIDE_SPELLINGS[ + "rank_refresh_defer_all_incremental_reindexes" +] +RANK_REFRESH_POLICIES = tuple( + entry["canonical"]["value"] + for entry in CONFIG_SPELLING_SPEC["config_overrides"] + if entry["canonical"]["key"] == "rank_refresh" +) +PRODUCT_DEFAULT_GRAPH_CAPABILITIES = { + "auto_index_deps": "false", + "rank_enabled": "true", + "similarity_enabled": "true", + "semantic_edges_enabled": "true", + "githistory_enabled": "true", + "httplinks_enabled": "true", +} + + +def product_default_graph_capabilities(**changes: str) -> dict[str, str]: + values = dict(PRODUCT_DEFAULT_GRAPH_CAPABILITIES) + values.update(changes) + return values + + +MINIMAL_INDEXING_CAPABILITIES = product_default_graph_capabilities( + rank_enabled="false", + similarity_enabled="false", + semantic_edges_enabled="false", + githistory_enabled="false", + httplinks_enabled="false", +) + + +CONFIG_PROFILES: dict[str, dict[str, str]] = { + CONFIG_PROFILE_CANDIDATE_NATIVE: {}, + CONFIG_PROFILE_AUTOMATIC_DEPENDENCY_SOURCE_INDEXING_DISABLED: product_default_graph_capabilities(), + CONFIG_PROFILE_AUTOMATIC_DEPENDENCY_SOURCE_INDEXING_ENABLED: product_default_graph_capabilities( + auto_index_deps="true" + ), + CONFIG_PROFILE_RANK_DISABLED: product_default_graph_capabilities( + rank_enabled="false" + ), + CONFIG_PROFILE_SIMILARITY_DISABLED: product_default_graph_capabilities( + similarity_enabled="false" + ), + CONFIG_PROFILE_SEMANTIC_EDGES_DISABLED: product_default_graph_capabilities( + semantic_edges_enabled="false" + ), + CONFIG_PROFILE_GIT_HISTORY_DISABLED: product_default_graph_capabilities( + githistory_enabled="false" + ), + CONFIG_PROFILE_HTTP_LINKS_DISABLED: product_default_graph_capabilities( + httplinks_enabled="false" + ), + CONFIG_PROFILE_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH: { + **product_default_graph_capabilities(), + DERIVED_RESULTS_AT_PUBLISH_OVERRIDE["key"]: DERIVED_RESULTS_AT_PUBLISH_OVERRIDE[ + "value" + ], + }, + CONFIG_PROFILE_OPTIONAL_GRAPH_DISABLED: MINIMAL_INDEXING_CAPABILITIES, + CONFIG_PROFILE_MINIMAL_INDEXING: MINIMAL_INDEXING_CAPABILITIES, +} +INDEX_MODES = ("fast", "moderate", "full") +PROJECT_DB_SUFFIX = ".db" +CONFIG_DB_NAME = "_config.db" +# Keep benchmark cache discovery aligned with CBM_DEP_SEPARATOR in +# src/depindex/depindex.h. Dependency indexes are separate project databases, +# not ambiguous candidates for the benchmark workload's primary database. +DEPENDENCY_PROJECT_SEPARATOR = ".dep." +LOG_TAIL_LINES = 24 +FAILURE_TAIL_LINES = 80 +FAILURE_ARTIFACT_DIRNAME = "failures" +FAILURE_FALLBACK_DIRNAME = "cbm-benchmark-failures" +FAILURE_TIMESTAMP_FORMAT = "%Y-%m-%d-%H%M%SZ" +MCP_INIT_PROTOCOL_VERSION = "2024-11-05" +MCP_CAPABILITY_SURFACES = ( + ( + "structural_search", + "structural and semantic symbol lookup", + ("search_graph",), + ("search_graph",), + ), + ( + "programmable_graph_analysis", + "problem-specific read-only Cypher", + ("query_graph",), + ("query_graph",), + ), + ( + "source_text_search", + "literal and regular-expression source lookup", + ("search_code",), + ("search_code",), + ), + ( + "call_path_analysis", + "inbound, outbound, and bidirectional call tracing", + ("trace_path",), + ("trace_path",), + ), + ( + "source_retrieval", + "qualified-symbol source retrieval", + ("get_code_snippet",), + ("get_code",), + ), + ( + "explicit_index_control", + "explicit repository indexing", + ("index_repository",), + (), + ), + ( + "schema_and_architecture", + "graph schema and architecture diagnostics", + ("get_graph_schema", "get_architecture"), + (), + ), + ( + "index_diagnostics", + "freshness, inventory, and coverage diagnostics", + ("index_status", "list_projects", "check_index_coverage"), + (), + ), + ("change_impact", "git-change blast-radius analysis", ("detect_changes",), ()), + ( + "dependency_sources", + "local dependency source indexing", + ("index_dependencies",), + (), + ), + ("project_lifecycle", "indexed-project deletion", ("delete_project",), ()), + ( + "architecture_evidence", + "ADR storage and runtime-trace ingest request surfaces", + ("manage_adr", "ingest_traces"), + (), + ), +) +MATRIX_SCENARIOS_DEFAULT = "go_modify_1,go_modify_2,go_create,go_delete,go_rename,go_new_folder,route_decorator,python_reexport" +MATRIX_REAL_REPO_SCENARIOS = frozenset({"fastapi_insert_probe"}) +CAPABILITY_QUALITY_CASES = ( + "rank", + "dependencies", + "similarity", + "semantic_edges", + "git_history", + "http_links", +) +CROSS_FILE_RESOLVER_LANGUAGES = ( + "go", + "c", + "cpp", + "cuda", + "python", + "javascript", + "typescript", + "tsx", + "php", + "csharp", + "java", + "kotlin", + "rust", +) +SCOPED_EXACT_FRONTIER_LANGUAGES = frozenset({"go", "c", "cpp", "cuda", "python"}) +MATRIX_FRONTIER_SCENARIOS = { + "go_inbound_frontier": "go", + "python_inbound_frontier": "python", + "c_header_inbound_frontier": "c_header", + "cpp_inbound_frontier": "cpp", + "cuda_inbound_frontier": "cuda", + "javascript_inbound_frontier": "javascript", + "typescript_inbound_frontier": "typescript", + "tsx_inbound_frontier": "tsx", + "php_inbound_frontier": "php", + "csharp_inbound_frontier": "csharp", + "java_inbound_frontier": "java", + "kotlin_inbound_frontier": "kotlin", + "rust_inbound_frontier": "rust", +} +SELF_DOGFOOD_SCENARIOS_DEFAULT = ( + "noop,one_source_file,route_handler,store_pipeline_batch,multi_file_small" +) +SELF_DOGFOOD_MARKER_PREFIX = "cbm_pan4_oracle" +SELF_DOGFOOD_REPO_SUBDIR = "repo" +SELF_DOGFOOD_CACHE_SUBDIR = "cache" +SELF_DOGFOOD_SCENARIO_PATHS = { + "noop": (), + "one_source_file": ("src/pipeline/pipeline_internal.h",), + "route_handler": ("src/ui/http_server.c",), + "c_new_leaf": ("src/cbm_benchmark_leaf.c",), + "store_pipeline_batch": ( + "src/store/store.h", + "src/pipeline/pipeline_internal.h", + ), + "multi_file_small": ("src/mcp/mcp.c", "tests/test_mcp.c"), +} +FASTAPI_PROBE_REL_PATH = "fastapi/routing.py" +FASTAPI_PROBE_INSERT_BEFORE = "\n def add_api_route(\n" +FASTAPI_PROBE_RETURN_VALUE = 64 +PUBLISH_FULL = "full" +PUBLISH_INCREMENTAL_NOOP = "incremental_noop" +PUBLISH_INCREMENTAL_EXACT = "incremental_exact" +PUBLISH_INCREMENTAL_OVERLAY = "incremental_overlay" +PUBLISH_INCREMENTAL_CONTAINMENT = "incremental_containment" +OVERLAY_STATUS_READY = "overlay_ready" # CBM_STORE_OVERLAY_STATUS_READY +OVERLAY_TOMBSTONE_FILE = "file" # CBM_STORE_OVERLAY_TOMBSTONE_FILE +OVERLAY_TOMBSTONE_ACTIVE = 1 # STORE_OVERLAY_TOMBSTONE_ACTIVE +OVERLAY_ROW_OWNED = 1 # STORE_OVERLAY_ROW_OWNED +SOURCE_SPAN_LABELS = frozenset( + { + "Function", + "Method", + "Class", + "Struct", + "Interface", + "Enum", + "Type", + "Trait", + "Module", + } +) +LOG_MARKER_PIPELINE_DONE = "pipeline.done" +LOG_MARKER_INCREMENTAL_DONE = "incremental.done" +LOG_MARKER_EXACT_DONE = "incremental.exact.done" +LOG_MARKER_EXACT_FRONTIER = "incremental.exact.frontier" +LOG_MARKER_EXACT_FALLBACK = "incremental.exact.fallback" +LOG_MARKER_EXACT_DELETE_FALLBACK = "incremental.exact.delete.fallback" +LOG_MARKER_EXACT_SKIP = "incremental.exact.skip" +LOG_MARKER_DEP_AUTO_INDEX = "sub=dep_auto_index" +LOG_MARKER_RANK_REFRESH = "phase=index_repository sub=rank_refresh" +LOG_MARKER_INDEX_WORKER_TOTAL = "phase=index_repository sub=TOTAL" +INDEXED_QUERY_PROFILE_COMPONENTS = ( + ("resolve_store", "open_validate"), + ("resolve_store", "integrity"), + ("resolve_store", "project_lookup"), + ("resolve_store", "session_sync"), + ("request_release_store", "store_close"), + ("request_release_store", "mem_collect"), + ("index_status", "resolve_project"), + ("index_status", "graph_counts"), + ("index_status", "dependency_inventory"), + ("index_status", "project_coverage"), + ("index_status", "pagerank"), + ("index_status", "freshness_overlay"), + ("index_status", "serialize"), +) + + +class BenchmarkCommandError(RuntimeError): + def __init__(self, message: str, detail: dict[str, Any]) -> None: + super().__init__(message) + self.detail = detail + + +def now_ms() -> float: + return time.perf_counter() * 1000.0 + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp") + try: + with temporary.open("w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def canonical_json_bytes(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def unknown_fact(reason: str) -> dict[str, str]: + return {"status": "unknown", "reason": reason} + + +def benchmark_run_context() -> dict[str, Any]: + raw = os.environ.get(BENCHMARK_RUN_CONTEXT_ENV) + if not raw: + return {} + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError( + f"{BENCHMARK_RUN_CONTEXT_ENV} must contain valid JSON" + ) from exc + if not isinstance(value, dict): + raise ValueError(f"{BENCHMARK_RUN_CONTEXT_ENV} must contain a JSON object") + return value + + +def benchmark_harness_metadata() -> dict[str, Any]: + script = Path(__file__).resolve() + return { + "path": str(script), + "sha256": file_sha256(script), + "fact_schema_version": BENCHMARK_FACT_SCHEMA_VERSION, + "terminology_path": str(BENCHMARK_TERMINOLOGY_PATH), + "terminology_version": BENCHMARK_TERMINOLOGY_VERSION, + "terminology_sha256": BENCHMARK_TERMINOLOGY_SHA256, + } + + +def report_mode(report: dict[str, Any]) -> str: + mode = report.get("mode") + if isinstance(mode, str) and mode: + return mode + if isinstance(report.get("cases"), list): + return "legacy_cases" + return "incremental_speed" + + +def report_implementation_identity( + report: dict[str, Any], context: dict[str, Any] +) -> dict[str, Any]: + binary = report.get("binary_metadata") + binary = ( + binary if isinstance(binary, dict) else unknown_fact("binary_metadata_missing") + ) + revision = context.get("revision") + revision_source = str(context.get("revision_source") or "experiment_cell") + allow_report_revision_fallback = context.get("label") != "standalone" + if ( + not isinstance(revision, str) or not revision + ) and allow_report_revision_fallback: + source_git = report.get("source_git") + revision = source_git.get("head") if isinstance(source_git, dict) else None + revision_source = "source_git.head" + if ( + not isinstance(revision, str) or not revision + ) and allow_report_revision_fallback: + background = report.get("repository_background") + revision = background.get("revision") if isinstance(background, dict) else None + revision_source = "repository_background.revision" + revision_value: Any = revision + if not isinstance(revision_value, str) or not revision_value: + revision_value = unknown_fact("legacy_report_did_not_record_candidate_revision") + revision_source = "unavailable" + build = context.get("build") + if not isinstance(build, dict): + build = unknown_fact("legacy_report_did_not_record_build_metadata") + return { + "revision": revision_value, + "revision_source": revision_source, + "binary": binary, + "build": build, + } + + +def report_capability_manifest( + report: dict[str, Any], context: dict[str, Any] +) -> dict[str, Any]: + parameters = report.get("parameters") + parameters = parameters if isinstance(parameters, dict) else {} + declared = context.get("capabilities") + declared = dict(declared) if isinstance(declared, dict) else {} + overrides = parameters.get("config_overrides") + requested_overrides = dict(overrides) if isinstance(overrides, dict) else {} + if isinstance(overrides, dict): + declared.update(overrides) + for key in ("index_mode", "rank_refresh", "config_profile", "transport"): + value = parameters.get(key) + if value is not None: + declared[key] = value + candidate_native = ( + parameters.get("config_profile") == CONFIG_PROFILE_CANDIDATE_NATIVE + ) + context_declared = context.get("capabilities") is not None + complete = context_declared and not candidate_native and not report.get("error") + if complete: + provenance = "experiment_cell_plus_isolated_successful_config_set_arguments" + elif context_declared: + provenance = "candidate_native_configuration" + else: + provenance = "legacy_report_parameters_only" + return { + "values": dict(sorted(declared.items())), + "requested_config_overrides": dict(sorted(requested_overrides.items())), + "effective_config_overrides": ( + unknown_fact("candidate_native_configuration_was_not_overridden") + if candidate_native + else dict(sorted(requested_overrides.items())) + ), + "completeness": "complete_declared_cell" if complete else "partial", + "provenance": provenance, + "missing_behavior": ( + "none for declared capability keys" + if complete + else "unknown values prohibit parity joins" + ), + } + + +def report_scope_manifest(report: dict[str, Any]) -> dict[str, Any]: + recorded = report.get("scope") + if isinstance(recorded, dict): + return recorded + parameters = report.get("parameters") + parameters = parameters if isinstance(parameters, dict) else {} + background = report.get("repository_background") + generated_source_policy: Any = unknown_fact( + "report_did_not_record_generated_source_policy" + ) + if report_mode(report) == "incremental_speed" and isinstance( + parameters.get("files"), int + ): + generated_source_policy = { + "kind": "deterministic_generated_go_fixture", + "generator": "create_repo/go_file_content", + "mutation": "modify_existing_files revision_offset_1000", + "provenance": "benchmark_harness_contract", + } + scope: dict[str, Any] = { + "workload": report_mode(report), + "files": parameters.get("files", unknown_fact("file_count_not_recorded")), + "functions_per_file": parameters.get( + "functions_per_file", unknown_fact("function_count_not_recorded") + ), + "changed_files": parameters.get( + "changed_files", unknown_fact("changed_file_count_not_recorded") + ), + "generated_source_policy": generated_source_policy, + } + if isinstance(background, dict): + scope["repository_background"] = background + elif isinstance(report.get("source_repo"), str): + scope["repository"] = report["source_repo"] + return scope + + +def report_cache_manifest( + report: dict[str, Any], imported_report: bool +) -> dict[str, Any]: + recorded = report.get("cache") + if isinstance(recorded, dict): + return recorded + if imported_report: + return { + "process": unknown_fact( + "imported_report_did_not_record_process_cache_state" + ), + "repository_graph": unknown_fact( + "imported_report_did_not_record_repository_graph_cache_state" + ), + "dependency_artifacts": unknown_fact( + "imported_report_did_not_record_dependency_cache_identity" + ), + "os_page_cache": unknown_fact( + "imported_report_did_not_record_os_page_cache_state" + ), + "sqlite_page_cache": unknown_fact( + "imported_report_did_not_record_sqlite_page_cache_state" + ), + "parser_compiler_cache": unknown_fact( + "imported_report_did_not_record_parser_compiler_cache_state" + ), + "fixture_cache": unknown_fact( + "imported_report_did_not_record_fixture_cache_state" + ), + } + parameters = report.get("parameters") + parameters = parameters if isinstance(parameters, dict) else {} + transport = parameters.get("transport") + return { + "process": { + "state": "persistent_within_lifecycle" + if transport == "mcp" + else "new_per_tool_call", + "source": "transport_contract" + if transport in {"cli", "mcp"} + else "unknown", + }, + "repository_graph": { + "initial_state": "empty_harness_owned_cache", + "reset_procedure": "remove_project_dbs_before_clean_rebuild", + }, + "dependency_artifacts": unknown_fact( + "legacy_harness_did_not_record_dependency_cache_identity" + ), + "os_page_cache": unknown_fact("os_page_cache_state_not_controlled"), + "sqlite_page_cache": unknown_fact("sqlite_page_cache_state_not_recorded"), + "parser_compiler_cache": unknown_fact( + "parser_compiler_cache_state_not_recorded" + ), + "fixture_cache": unknown_fact("fixture_cache_state_not_recorded"), + } + + +STEP_ID_BY_FIELD = { + "initial_fast_full": "initial_index", + "incremental": "incremental_index", + "incremental_exact": "incremental_index", + "fresh_fast_full_after_change": "clean_rebuild_index", + "fresh_full_after_change": "clean_rebuild_index", +} + + +def fact_step_rows(report: dict[str, Any], run_id: str) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + seen_objects: set[int] = set() + + def visit( + value: Any, path: tuple[str, ...], parent_occurrence_id: str | None + ) -> None: + if isinstance(value, dict): + object_id = id(value) + if object_id in seen_objects: + return + seen_objects.add(object_id) + elapsed = value.get("elapsed_ms") + current_parent = parent_occurrence_id + if isinstance(elapsed, (int, float)) and not isinstance(elapsed, bool): + field = path[-1] if path else "operation" + step_id = STEP_ID_BY_FIELD.get(field, field) + occurrence_id = hashlib.sha256( + canonical_json_bytes({"run_id": run_id, "path": path}) + ).hexdigest()[:24] + row = { + "run_id": run_id, + "step_id": step_id, + "occurrence_id": occurrence_id, + "source_path": ".".join(path), + "parent_occurrence_id": parent_occurrence_id, + "dependency_occurrence_ids": [], + "elapsed_ms": float(elapsed), + "monotonic_start_ns": unknown_fact( + "profile_marker_did_not_record_start_timestamp" + ), + "monotonic_end_ns": unknown_fact( + "profile_marker_did_not_record_end_timestamp" + ), + "cpu_ms": unknown_fact("cpu_time_not_recorded"), + "cpu_scope": "unknown", + "queue_wait_ms": unknown_fact("queue_wait_not_recorded"), + "thread_or_worker_id": unknown_fact("worker_identity_not_recorded"), + "critical_path": unknown_fact("dependency_event_dag_not_recorded"), + "peak_rss_mb": value.get( + "peak_rss_mb", unknown_fact("peak_rss_not_recorded") + ), + "work_counters": {}, + "provenance": "normalized_report_measurement", + } + rows.append(row) + current_parent = occurrence_id + components = value.get("timing_components_ms") + if isinstance(components, dict): + for name, duration in sorted(components.items()): + if not isinstance(duration, (int, float)) or isinstance( + duration, bool + ): + continue + component_occurrence = hashlib.sha256( + canonical_json_bytes( + {"run_id": run_id, "path": path, "component": name} + ) + ).hexdigest()[:24] + rows.append( + { + "run_id": run_id, + "step_id": str(name), + "occurrence_id": component_occurrence, + "source_path": ".".join( + (*path, "timing_components_ms", name) + ), + "parent_occurrence_id": occurrence_id, + "dependency_occurrence_ids": [], + "elapsed_ms": float(duration), + "monotonic_start_ns": unknown_fact( + "component_marker_did_not_record_start_timestamp" + ), + "monotonic_end_ns": unknown_fact( + "component_marker_did_not_record_end_timestamp" + ), + "cpu_ms": unknown_fact("cpu_time_not_recorded"), + "cpu_scope": "unknown", + "queue_wait_ms": unknown_fact( + "queue_wait_not_recorded" + ), + "thread_or_worker_id": unknown_fact( + "worker_identity_not_recorded" + ), + "critical_path": unknown_fact( + "dependency_event_dag_not_recorded" + ), + "peak_rss_mb": unknown_fact( + "component_peak_rss_not_recorded" + ), + "work_counters": {}, + "provenance": "parsed_existing_profile_marker", + } + ) + for key, child in value.items(): + if ( + key == "incremental" + and "incremental_exact" in value + and value["incremental_exact"] == child + ): + continue + visit(child, (*path, str(key)), current_parent) + elif isinstance(value, list): + for index, child in enumerate(value): + visit(child, (*path, str(index)), parent_occurrence_id) + + measurements = report.get("measurements") + if isinstance(measurements, dict): + visit(measurements, ("measurements",), None) + cases = report.get("cases") + if isinstance(cases, list): + visit(cases, ("cases",), None) + return rows + + +def fact_result_rows(report: dict[str, Any], run_id: str) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + derived = report.get("derived") + passed = derived.get("passed") if isinstance(derived, dict) else None + rows.append( + { + "run_id": run_id, + "result_id": "benchmark_gate", + "kind": "product_contract", + "status": "passed" + if passed is True + else "failed" + if passed is False + else "unknown", + "value": passed + if isinstance(passed, bool) + else unknown_fact("derived_passed_missing"), + "provenance": "report.derived.passed", + } + ) + error = report.get("error") + if error is not None: + rows.append( + { + "run_id": run_id, + "result_id": "harness_error", + "kind": "instrumentation", + "status": "failed", + "value": error, + "provenance": "report.error", + } + ) + cases = report.get("cases") + if isinstance(cases, list): + for index, case in enumerate(cases): + if not isinstance(case, dict): + continue + scenario = case.get("scenario") + scenario_id = ( + re.sub(r"[^a-zA-Z0-9_.-]+", "_", scenario) + if isinstance(scenario, str) and scenario + else str(index) + ) + case_passed = case.get("passed") + rows.append( + { + "run_id": run_id, + "result_id": f"case.{scenario_id}.contract", + "kind": "correctness_contract", + "status": ( + "passed" + if case_passed is True + else "failed" + if case_passed is False + else "unknown" + ), + "value": { + "scenario": scenario, + "publish_kind": ( + case.get("incremental", {}).get("publish_kind") + if isinstance(case.get("incremental"), dict) + else None + ), + "exact_reason": case.get("exact_reason"), + }, + "provenance": f"report.cases[{index}]", + } + ) + for field, kind in ( + ("graph_gate", "graph_oracle"), + ("canonical_graph", "graph_equality"), + ("freshness_scoped_graph", "freshness_oracle"), + ): + value = case.get(field) + if not isinstance(value, dict): + continue + passed_value = value.get("passed", value.get("equal")) + rows.append( + { + "run_id": run_id, + "result_id": f"case.{scenario_id}.{field}", + "kind": kind, + "status": ( + "passed" + if passed_value is True + else "failed" + if passed_value is False + else "unknown" + ), + "value": { + key: value[key] + for key in ( + "policy", + "reason", + "equal", + "canonical_equal", + "freshness_scoped_equal", + "declared_stale_views", + "excluded_edge_types", + "left_count", + "right_count", + "left_sha256", + "right_sha256", + ) + if key in value + }, + "provenance": f"report.cases[{index}].{field}", + } + ) + oracles = case.get("oracles") + if not isinstance(oracles, dict): + continue + quality = oracles.get("quality") + if isinstance(quality, dict): + quality_passed = quality.get("passed") + rows.append( + { + "run_id": run_id, + "result_id": f"case.{scenario_id}.quality", + "kind": "retrieval_quality", + "status": ( + "passed" + if quality_passed is True + else "failed" + if quality_passed is False + else "unknown" + ), + "value": dict(quality), + "provenance": f"report.cases[{index}].oracles.quality", + } + ) + for oracle_name, oracle in sorted(oracles.items()): + if oracle_name in {"passed", "quality"} or not isinstance(oracle, dict): + continue + oracle_quality = oracle.get("quality") + if not isinstance(oracle_quality, dict): + continue + applicable = oracle_quality.get("applicable") + oracle_passed = oracle_quality.get("passed") + rows.append( + { + "run_id": run_id, + "result_id": ( + f"case.{scenario_id}.oracle." + + re.sub(r"[^a-zA-Z0-9_.-]+", "_", oracle_name) + ), + "kind": "retrieval_task", + "status": ( + "skipped" + if applicable is False + else "passed" + if oracle_passed is True + else "failed" + if oracle_passed is False + else "unknown" + ), + "value": { + "scenario": scenario, + "criterion": oracle_quality.get("criterion"), + "expected_substring": oracle_quality.get( + "expected_substring" + ), + "applicable": applicable, + "rank": oracle_quality.get("rank"), + "reciprocal_rank": oracle_quality.get("reciprocal_rank"), + "hit_at_1": oracle_quality.get("hit_at_1"), + "hit_at_5": oracle_quality.get("hit_at_5"), + "ndcg_at_5": oracle_quality.get("ndcg_at_5"), + "freshness": oracle.get("freshness"), + "freshness_state": oracle.get("freshness_state"), + }, + "provenance": ( + f"report.cases[{index}].oracles.{oracle_name}.quality" + ), + } + ) + return rows + + +def fact_artifact_rows(report: dict[str, Any], run_id: str) -> list[dict[str, Any]]: + """Normalize path-based and content-addressed log metadata from any report branch. + + Visiting N report values and A distinct artifacts costs O(N + A) time and + O(A) memory for the emitted rows and deduplication set. + """ + rows: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + + def visit(value: Any) -> None: + if isinstance(value, dict): + artifacts = value.get("measurement_log_artifacts") + if isinstance(artifacts, list): + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + path = ( + artifact.get("path") + or artifact.get("artifact_path") + or artifact.get("artifact_name") + ) + digest = artifact.get("sha256") or artifact.get("artifact_sha256") + if not isinstance(path, str) or not isinstance(digest, str): + continue + key = (path, digest) + if key in seen: + continue + seen.add(key) + rows.append( + { + "run_id": run_id, + "artifact_id": hashlib.sha256( + canonical_json_bytes(key) + ).hexdigest()[:24], + "artifact_type": "measurement_log", + "path": path, + "sha256": digest, + "size_bytes": artifact.get( + "size_bytes", + artifact.get( + "artifact_bytes", + unknown_fact("artifact_size_not_recorded"), + ), + ), + "schema_version": unknown_fact( + "unstructured_measurement_log" + ), + "cleanup_status": "retained", + } + ) + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + + visit(report) + return rows + + +def normalize_benchmark_report( + report: dict[str, Any], + context: dict[str, Any] | None = None, + *, + imported_report: bool | None = None, +) -> dict[str, Any]: + if not isinstance(report, dict): + raise ValueError("benchmark report must be a JSON object") + resolved_context = dict(context or {}) + is_imported_report = ( + not bool(resolved_context) if imported_report is None else imported_report + ) + implementation = report_implementation_identity(report, resolved_context) + run_identity = { + "generated_at_utc": report.get("generated_at_utc"), + "mode": report_mode(report), + "implementation": implementation, + "measurement_checkout": resolved_context.get( + "source_git", unknown_fact("measurement_checkout_not_recorded") + ), + "cell_identity": resolved_context.get("cell_identity"), + "repetition": resolved_context.get("repetition"), + "parameters": report.get("parameters"), + } + run_id = hashlib.sha256(canonical_json_bytes(run_identity)).hexdigest()[:24] + recorded_host = report.get("host") + if not isinstance(recorded_host, dict): + recorded_host = report.get("host_metadata") + if not isinstance(recorded_host, dict): + recorded_host = ( + { + "platform": platform.platform(), + "machine": platform.machine(), + "python": platform.python_version(), + "provenance": "measurement_process", + } + if resolved_context + else unknown_fact("legacy_report_did_not_record_host_metadata") + ) + run_row = { + "run_id": run_id, + "lifecycle_id": run_id, + "generated_at_utc": report.get( + "generated_at_utc", unknown_fact("legacy_report_timestamp_missing") + ), + "mode": report_mode(report), + "cell_identity": resolved_context.get( + "cell_identity", unknown_fact("not_executed_by_experiment_runner") + ), + "cell_label": resolved_context.get( + "label", unknown_fact("cell_label_not_recorded") + ), + "repetition": resolved_context.get( + "repetition", unknown_fact("repetition_not_recorded") + ), + "implementation": implementation, + "harness": benchmark_harness_metadata(), + "host": recorded_host, + "measurement_checkout": resolved_context.get( + "source_git", unknown_fact("measurement_checkout_not_recorded") + ), + "capabilities": report_capability_manifest(report, resolved_context), + "scope": report_scope_manifest(report), + "cache": report_cache_manifest(report, is_imported_report), + "legacy_import": is_imported_report, + } + return { + "$schema": BENCHMARK_FACT_SCHEMA, + "schema_version": BENCHMARK_FACT_SCHEMA_VERSION, + "terminology_version": BENCHMARK_TERMINOLOGY_VERSION, + "terminology_sha256": BENCHMARK_TERMINOLOGY_SHA256, + "generator_revision": benchmark_harness_metadata()["sha256"], + "runs": [run_row], + "steps": fact_step_rows(report, run_id), + "results": fact_result_rows(report, run_id), + "artifacts": fact_artifact_rows(report, run_id), + } + + +def validate_benchmark_facts( + facts: dict[str, Any], *, require_current_contract: bool = True +) -> None: + if facts.get("schema_version") != BENCHMARK_FACT_SCHEMA_VERSION: + raise ValueError("benchmark facts schema_version is unsupported") + terminology_version = facts.get("terminology_version") + if not isinstance(terminology_version, str) or not re.fullmatch( + r"[1-9]\d*\.\d+\.\d+", terminology_version + ): + raise ValueError("benchmark facts terminology_version must be semantic") + terminology_sha256 = facts.get("terminology_sha256") + if not isinstance(terminology_sha256, str) or not re.fullmatch( + r"[0-9a-f]{64}", terminology_sha256 + ): + raise ValueError("benchmark facts terminology_sha256 must be a SHA-256") + if require_current_contract and ( + terminology_version != BENCHMARK_TERMINOLOGY_VERSION + or terminology_sha256 != BENCHMARK_TERMINOLOGY_SHA256 + ): + raise ValueError( + "benchmark facts terminology contract does not match the current registry" + ) + generator_revision = facts.get("generator_revision") + if not isinstance(generator_revision, str) or not re.fullmatch( + r"[0-9a-f]{64}", generator_revision + ): + raise ValueError("benchmark facts generator_revision must be a SHA-256") + for table in ("runs", "steps", "results", "artifacts"): + rows = facts.get(table) + if not isinstance(rows, list): + raise ValueError(f"benchmark facts {table} must be an array") + if len(facts["runs"]) != 1 or not isinstance(facts["runs"][0], dict): + raise ValueError("benchmark facts must contain exactly one run row") + run = facts["runs"][0] + required_run_fields = { + "run_id", + "lifecycle_id", + "generated_at_utc", + "mode", + "implementation", + "harness", + "host", + "measurement_checkout", + "capabilities", + "scope", + "cache", + "legacy_import", + } + missing_run_fields = sorted(required_run_fields - run.keys()) + if missing_run_fields: + raise ValueError( + "benchmark facts run row is missing required fields: " + + ", ".join(missing_run_fields) + ) + run_id = run.get("run_id") + if not isinstance(run_id, str) or not re.fullmatch(r"[0-9a-f]{24}", run_id): + raise ValueError("benchmark fact run_id must be 24 lowercase hex characters") + for table in ("steps", "results", "artifacts"): + for row in facts[table]: + if not isinstance(row, dict) or row.get("run_id") != run_id: + raise ValueError(f"benchmark facts {table} row has a foreign run_id") + occurrence_ids = [row.get("occurrence_id") for row in facts["steps"]] + if len(occurrence_ids) != len(set(occurrence_ids)): + raise ValueError("benchmark fact step occurrence IDs must be unique") + + +def load_benchmark_fact_bundle(path: Path) -> dict[str, Any]: + """Load current or retained v1 facts without inventing missing v1 metadata.""" + document = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("benchmark fact bundle must be a JSON object") + version = document.get("schema_version") + if version == BENCHMARK_FACT_SCHEMA_VERSION: + if document.get("$schema") not in BENCHMARK_FACT_COMPATIBLE_SCHEMA_URIS: + raise ValueError( + "benchmark fact bundle schema URI does not match its version" + ) + validate_benchmark_facts(document, require_current_contract=False) + return document + legacy_schema = BENCHMARK_FACT_LEGACY_SCHEMAS.get(version) + if legacy_schema is None: + raise ValueError(f"unsupported benchmark fact schema_version: {version!r}") + if document.get("$schema") != legacy_schema: + raise ValueError("legacy benchmark fact bundle schema URI is invalid") + for table in ("runs", "steps", "results", "artifacts"): + if not isinstance(document.get(table), list): + raise ValueError(f"legacy benchmark facts {table} must be an array") + if len(document["runs"]) != 1 or not isinstance(document["runs"][0], dict): + raise ValueError("legacy benchmark facts must contain exactly one run row") + run_id = document["runs"][0].get("run_id") + if not isinstance(run_id, str) or not re.fullmatch(r"[0-9a-f]{24}", run_id): + raise ValueError("legacy benchmark fact run_id is invalid") + for table in ("steps", "results", "artifacts"): + if any( + not isinstance(row, dict) or row.get("run_id") != run_id + for row in document[table] + ): + raise ValueError(f"legacy benchmark facts {table} row has a foreign run_id") + return document + + +def write_benchmark_fact_tables(facts: dict[str, Any], root: Path) -> dict[str, Any]: + validate_benchmark_facts(facts) + root.mkdir(parents=True, exist_ok=True) + files: dict[str, dict[str, Any]] = {} + bundle_path = root / "facts.json" + atomic_write_text(bundle_path, json.dumps(facts, indent=2, sort_keys=True) + "\n") + files["bundle"] = { + "path": str(bundle_path), + "sha256": file_sha256(bundle_path), + "rows": sum( + len(facts[table]) for table in ("runs", "steps", "results", "artifacts") + ), + } + for table in ("runs", "steps", "results", "artifacts"): + path = root / ("steps.jsonl" if table == "steps" else f"{table}.json") + if table == "steps": + payload = "".join( + json.dumps(row, separators=(",", ":"), sort_keys=True) + "\n" + for row in facts[table] + ) + else: + payload = json.dumps(facts[table], indent=2, sort_keys=True) + "\n" + atomic_write_text(path, payload) + files[table] = { + "path": str(path), + "sha256": file_sha256(path), + "rows": len(facts[table]), + } + manifest = { + "schema_version": BENCHMARK_FACT_SCHEMA_VERSION, + "terminology_version": BENCHMARK_TERMINOLOGY_VERSION, + "terminology_sha256": BENCHMARK_TERMINOLOGY_SHA256, + "generator_revision": facts["generator_revision"], + "run_id": facts["runs"][0]["run_id"], + "files": files, + } + manifest_path = root / "manifest.json" + atomic_write_text( + manifest_path, json.dumps(manifest, indent=2, sort_keys=True) + "\n" + ) + manifest["manifest_path"] = str(manifest_path) + manifest["manifest_sha256"] = file_sha256(manifest_path) + return manifest + + +def resolve_facts_dir(args: argparse.Namespace) -> Path | None: + if getattr(args, "facts_dir", ""): + return Path(args.facts_dir).expanduser() + artifact_dir = os.environ.get(BENCHMARK_ARTIFACT_DIR_ENV) + if artifact_dir: + return Path(artifact_dir).expanduser() / "facts" + if getattr(args, "out", ""): + output = Path(args.out).expanduser() + return output.parent / f"{output.stem}.facts" + return None + + +def standalone_run_context(args: argparse.Namespace) -> dict[str, Any]: + context: dict[str, Any] = { + "label": "standalone", + "repetition": 1, + "harness_version": benchmark_harness_metadata()["sha256"], + } + build = getattr(args, "build_metadata", None) + if isinstance(build, dict) and build: + context["build"] = build + candidates = [Path(args.binary).expanduser().resolve().parent] + repo_root = getattr(args, "repo_root", "") + if repo_root: + candidates.append(Path(repo_root).expanduser().resolve()) + for candidate in candidates: + try: + root = resolve_git_repo_root(candidate, args.timeout) + revision_arg = getattr(args, "candidate_revision", "") or "HEAD" + revision = command_stdout( + ["git", "rev-parse", f"{revision_arg}^{{commit}}"], args.timeout, root + ) + context["source_git"] = git_metadata(root, args.timeout) + if getattr(args, "candidate_revision", ""): + context["revision"] = revision + context["revision_source"] = "standalone_declared_revision" + else: + context["checkout_revision"] = revision + break + except (OSError, RuntimeError, subprocess.SubprocessError): + continue + return context + + +def emit_report(report: dict[str, Any], args: argparse.Namespace) -> None: + context = benchmark_run_context() + if not context: + context = standalone_run_context(args) + if context: + report["benchmark_run_context"] = context + facts = normalize_benchmark_report(report, context) + facts_dir = resolve_facts_dir(args) + if facts_dir is not None: + report["fact_manifest"] = write_benchmark_fact_tables(facts, facts_dir) + if args.out: + atomic_write_text( + Path(args.out).expanduser(), + json.dumps(report, indent=2, sort_keys=True) + "\n", + ) + print(json.dumps(report, indent=2, sort_keys=True)) + + +def archive_measurement_log(source: Path, artifact_dir: Path) -> dict[str, Any]: + """Stream one worker log into a content-addressed reproducible gzip artifact.""" + artifact_dir.mkdir(parents=True, exist_ok=True) + temporary = artifact_dir / f".worker-log-{os.getpid()}-{time.time_ns()}.tmp" + source_digest = hashlib.sha256() + source_bytes = 0 + try: + with source.open("rb") as input_stream, temporary.open("wb") as output_stream: + with gzip.GzipFile( + filename="", mode="wb", fileobj=output_stream, mtime=0 + ) as compressed: + for chunk in iter(lambda: input_stream.read(1024 * 1024), b""): + source_digest.update(chunk) + source_bytes += len(chunk) + compressed.write(chunk) + output_stream.flush() + os.fsync(output_stream.fileno()) + source_sha256 = source_digest.hexdigest() + artifact_name = f"{source_sha256}.log.gz" + destination = artifact_dir / artifact_name + if destination.exists(): + temporary.unlink() + else: + os.replace(temporary, destination) + return { + "artifact_name": artifact_name, + "source_name": source.name, + "source_bytes": source_bytes, + "source_sha256": source_sha256, + "artifact_bytes": destination.stat().st_size, + "artifact_sha256": file_sha256(destination), + "compression": "gzip-mtime-0", + } + finally: + if temporary.exists(): + temporary.unlink() + + +def go_file_content(index: int, revision: int, funcs_per_file: int) -> str: + lines = ["package main", ""] + for func_index in range(funcs_per_file): + value = index * funcs_per_file + func_index + revision + lines.extend( + [ + f"func Func{index:04d}_{func_index:02d}() int {{", + f"\treturn {value}", + "}", + "", + ] + ) + return "\n".join(lines) + + +def create_repo(repo_dir: Path, file_count: int, funcs_per_file: int) -> None: + write_text(repo_dir / "go.mod", "module example.com/cbmbench\n\ngo 1.22\n") + write_text(repo_dir / "main.go", "package main\n\nfunc main() {}\n") + for index in range(file_count): + write_text( + repo_dir / f"pkg/file_{index:04d}.go", + go_file_content(index, 0, funcs_per_file), + ) + + +def modify_existing_files( + repo_dir: Path, changed_files: int, funcs_per_file: int +) -> list[str]: + changed: list[str] = [] + for index in range(changed_files): + rel = Path("pkg") / f"file_{index:04d}.go" + write_text(repo_dir / rel, go_file_content(index, 1000, funcs_per_file)) + changed.append(rel.as_posix()) + return changed + + +def create_python_reexport_repo(repo_dir: Path) -> None: + write_text( + repo_dir / "fastapi" / "__init__.py", "from .param_functions import Header\n" + ) + write_text( + repo_dir / "fastapi" / "param_functions.py", + "def Header(default=None):\n return default\n", + ) + write_text( + repo_dir / "fastapi" / "openapi" / "models.py", "class Header:\n pass\n" + ) + write_text( + repo_dir / "docs_src" / "app" / "main.py", + "from fastapi import Header\n\ndef create_item():\n return Header(None)\n", + ) + + +def create_route_repo(repo_dir: Path, route_path: str) -> None: + write_text( + repo_dir / "routes.py", + "from fastapi import FastAPI\n\n" + "app = FastAPI()\n\n" + f"@app.get('{route_path}')\n" + "def orders():\n" + " return {'ok': True}\n", + ) + + +def create_rank_quality_repo(repo_dir: Path) -> dict[str, Any]: + """Create a lexical-decoy graph where structural rank identifies the useful result.""" + write_text( + repo_dir / "order_core.py", + "def zz_order_core(order):\n" + ' """Validate and persist the canonical order workflow."""\n' + " return {'accepted': bool(order)}\n", + ) + decoy_names = [f"a{letter}_order_stub" for letter in "abcdefgh"] + write_text( + repo_dir / "order_stubs.py", + "\n\n".join(f"def {name}(order):\n return order" for name in decoy_names) + + "\n", + ) + for index in range(8): + write_text( + repo_dir / f"caller_{index}.py", + "from order_core import zz_order_core\n\n" + f"def workflow_{index}(order):\n" + " return zz_order_core(order)\n", + ) + return { + "fixture_version": 1, + "capability": "rank", + "language": "python", + "relevant_symbol": "zz_order_core", + "lexical_decoys": decoy_names, + "ranking_signal": "eight distinct callers target the relevant symbol", + } + + +def create_dependency_quality_repo(repo_dir: Path) -> dict[str, Any]: + """Create a local npm dependency whose source can be auto-indexed without I/O.""" + package_name = "cbmbenchdep" + symbol = "canonicalDependencyAPI" + write_text( + repo_dir / "package.json", + json.dumps( + { + "name": "cbm-dependency-quality-fixture", + "version": "1.0.0", + "dependencies": {package_name: "1.0.0"}, + }, + indent=2, + sort_keys=True, + ) + + "\n", + ) + write_text( + repo_dir / "src" / "app.js", + f"import {{ {symbol} }} from '{package_name}';\n\n" + f"export function useDependency(value) {{ return {symbol}(value); }}\n", + ) + write_text( + repo_dir / "node_modules" / package_name / "package.json", + json.dumps( + {"name": package_name, "version": "1.0.0", "main": "index.js"}, + indent=2, + sort_keys=True, + ) + + "\n", + ) + write_text( + repo_dir / "node_modules" / package_name / "index.js", + f"export function {symbol}(value) {{ return {{ accepted: Boolean(value) }}; }}\n", + ) + return { + "fixture_version": 1, + "capability": "dependencies", + "language": "javascript", + "package_manager": "npm", + "package": package_name, + "relevant_symbol": symbol, + "source_resolution": f"node_modules/{package_name}", + "network_required": False, + } + + +def create_git_history_quality_repo(repo_dir: Path) -> dict[str, Any]: + """Create a deterministic four-commit co-change history for two source files.""" + alpha = repo_dir / "alpha.py" + beta = repo_dir / "beta.py" + git_env = os.environ.copy() + git_env.update( + { + "GIT_AUTHOR_NAME": "CBM Benchmark", + "GIT_AUTHOR_EMAIL": "benchmark@example.invalid", + "GIT_COMMITTER_NAME": "CBM Benchmark", + "GIT_COMMITTER_EMAIL": "benchmark@example.invalid", + } + ) + + def git(*arguments: str, commit_index: int | None = None) -> None: + env = git_env + if commit_index is not None: + env = git_env.copy() + timestamp = f"2026-01-{commit_index:02d}T00:00:00+00:00" + env["GIT_AUTHOR_DATE"] = timestamp + env["GIT_COMMITTER_DATE"] = timestamp + subprocess.run( + ["git", *arguments], + cwd=repo_dir, + env=env, + check=True, + capture_output=True, + text=True, + ) + + git("init", "-q") + for commit_index in range(1, 5): + write_text( + alpha, + alpha.read_text(encoding="utf-8") + + f"def alpha_{commit_index}():\n return {commit_index}\n\n" + if alpha.exists() + else f"def alpha_{commit_index}():\n return {commit_index}\n\n", + ) + write_text( + beta, + beta.read_text(encoding="utf-8") + + f"def beta_{commit_index}():\n return {commit_index}\n\n" + if beta.exists() + else f"def beta_{commit_index}():\n return {commit_index}\n\n", + ) + git("add", "--", "alpha.py", "beta.py") + git( + "commit", + "-q", + "-m", + f"coupled change {commit_index}", + commit_index=commit_index, + ) + return { + "fixture_version": 1, + "capability": "git_history", + "language": "python", + "coupled_paths": ["alpha.py", "beta.py"], + "expected_co_changes": 4, + "relationship": "FILE_CHANGES_WITH", + "network_required": False, + } + + +def create_http_links_quality_repo(repo_dir: Path) -> dict[str, Any]: + """Create a source-discovered Ktor route plus a cross-service HTTP client call.""" + concrete_path = "/api/cbmbench-orders/42" + route_template = "/api/cbmbench-orders/{order_id}" + write_text( + repo_dir / "server" / "Routes.kt", + "import io.ktor.server.application.*\n" + "import io.ktor.server.response.*\n" + "import io.ktor.server.routing.*\n\n" + "fun Application.configureRouting() {\n" + " routing {\n" + f' get("{route_template}") {{\n' + ' call.respondText("order")\n' + " }\n" + " }\n" + "}\n", + ) + write_text( + repo_dir / "client" / "service.py", + "import requests\n\n" + "def fetch_order():\n" + f" return requests.get('http://orders.invalid{concrete_path}')\n", + ) + return { + "fixture_version": 1, + "capability": "http_links", + "language": "python+kotlin", + "caller": "fetch_order", + "handler": "configureRouting", + "route_path": concrete_path, + "route_template": route_template, + "relationship": "HTTP_CALLS", + "network_required": False, + } + + +def canonical_json_sha256(value: Any) -> str: + payload = json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def create_pair_quality_repo(repo_dir: Path, capability: str) -> dict[str, Any]: + task_root = Path(__file__).resolve().parents[1] / "benchmarks" / "semantic-pairs-v1" + manifest_path = task_root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("schema_version") != 1: + raise ValueError("semantic pair manifest schema_version must be 1") + cases = manifest.get("cases") + case = cases.get(capability) if isinstance(cases, dict) else None + if not isinstance(case, dict): + raise ValueError(f"semantic pair manifest has no case for {capability}") + source_paths = case.get("source_paths") + if not isinstance(source_paths, list) or not source_paths: + raise ValueError(f"semantic pair case {capability} requires source_paths") + source_sha256: dict[str, str] = {} + for relative in source_paths: + if ( + not isinstance(relative, str) + or not relative + or Path(relative).is_absolute() + or ".." in Path(relative).parts + ): + raise ValueError("semantic pair source path must be relative") + source = task_root / relative + payload = source.read_bytes() + target = repo_dir / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(payload) + source_sha256[relative] = hashlib.sha256(payload).hexdigest() + mutation = case.get("mutation") + if not isinstance(mutation, dict): + raise ValueError(f"semantic pair case {capability} requires mutation") + replacement_relative = mutation.get("replacement_source_path") + target_relative = mutation.get("target_path") + if ( + not isinstance(replacement_relative, str) + or not replacement_relative + or Path(replacement_relative).is_absolute() + or ".." in Path(replacement_relative).parts + or target_relative not in source_paths + ): + raise ValueError(f"semantic pair case {capability} has invalid mutation paths") + replacement_payload = (task_root / replacement_relative).read_bytes() + mutation = { + **mutation, + "replacement_source_sha256": hashlib.sha256(replacement_payload).hexdigest(), + } + task_set = { + "schema_version": manifest["schema_version"], + "task_set_version": manifest["task_set_version"], + "ground_truth_scope": manifest["ground_truth_scope"], + "query_name_marker": manifest["query_name_marker"], + **case, + "mutation": mutation, + "source_sha256": source_sha256, + "manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), + } + return {**task_set, "task_set_sha256": canonical_json_sha256(task_set)} + + +def create_similarity_quality_repo(repo_dir: Path) -> dict[str, Any]: + return create_pair_quality_repo(repo_dir, "similarity") + + +def create_semantic_edges_quality_repo(repo_dir: Path) -> dict[str, Any]: + return create_pair_quality_repo(repo_dir, "semantic_edges") + + +def apply_pair_quality_mutation( + repo_dir: Path, fixture: dict[str, Any] +) -> dict[str, Any]: + mutation = fixture.get("mutation") + if not isinstance(mutation, dict): + raise ValueError("pair quality fixture has no mutation") + target_relative = str(mutation["target_path"]) + replacement_relative = str(mutation["replacement_source_path"]) + target = repo_dir / target_relative + before_payload = target.read_bytes() + before_sha256 = hashlib.sha256(before_payload).hexdigest() + expected_before = fixture.get("source_sha256", {}).get(target_relative) + if before_sha256 != expected_before: + raise ValueError( + f"pair quality mutation source hash mismatch for {target_relative}" + ) + task_root = Path(__file__).resolve().parents[1] / "benchmarks" / "semantic-pairs-v1" + replacement_payload = (task_root / replacement_relative).read_bytes() + after_sha256 = hashlib.sha256(replacement_payload).hexdigest() + if after_sha256 != mutation.get("replacement_source_sha256"): + raise ValueError("pair quality replacement source hash mismatch") + atomic_write_text(target, replacement_payload.decode("utf-8")) + return { + "description": mutation["description"], + "changed_paths": [target_relative], + "before_sha256": before_sha256, + "after_sha256": after_sha256, + "post_judgments": list(mutation["post_judgments"]), + } + + +def create_inbound_frontier_repo( + repo_dir: Path, language: str, dependent_files: int +) -> dict[str, Any]: + """Create one definition file with a requested number of inbound dependents.""" + if dependent_files <= 0: + raise ValueError("frontier files must be positive") + dependent_paths: list[str] = [] + if language == "go": + write_text(repo_dir / "go.mod", "module example.com/cbmfrontier\n\ngo 1.22\n") + write_text( + repo_dir / "leaf.go", "package frontier\n\nfunc Leaf() int { return 1 }\n" + ) + for index in range(dependent_files): + relative = f"caller_{index:04d}.go" + write_text( + repo_dir / relative, + "package frontier\n\n" + f"func Caller{index:04d}() int {{ return Leaf() + {index} }}\n", + ) + dependent_paths.append(relative) + changed_path = "leaf.go" + elif language == "python": + write_text(repo_dir / "leaf.py", "def leaf():\n return 1\n") + for index in range(dependent_files): + relative = f"caller_{index:04d}.py" + write_text( + repo_dir / relative, + "from leaf import leaf\n\n" + f"def caller_{index:04d}():\n return leaf() + {index}\n", + ) + dependent_paths.append(relative) + changed_path = "leaf.py" + elif language == "c_header": + write_text( + repo_dir / "shared.h", + "#ifndef SHARED_H\n" + "#define SHARED_H\n" + "static int shared_value(void) { return 1; }\n" + "#endif\n", + ) + for index in range(dependent_files): + relative = f"consumer_{index:04d}.c" + write_text( + repo_dir / relative, + '#include "shared.h"\n\n' + f"int consumer_{index:04d}(void) {{ return shared_value() + {index}; }}\n", + ) + dependent_paths.append(relative) + changed_path = "shared.h" + elif language in {"cpp", "cuda"}: + header_ext, source_ext = ("hpp", "cpp") if language == "cpp" else ("cuh", "cu") + changed_path = f"shared.{header_ext}" + write_text(repo_dir / changed_path, "inline int shared_value() { return 1; }\n") + for index in range(dependent_files): + relative = f"consumer_{index:04d}.{source_ext}" + write_text( + repo_dir / relative, + f'#include "{changed_path}"\n\n' + f"int consumer_{index:04d}() {{ return shared_value() + {index}; }}\n", + ) + dependent_paths.append(relative) + elif language in {"javascript", "typescript", "tsx"}: + extension = {"javascript": "js", "typescript": "ts", "tsx": "tsx"}[language] + changed_path = f"leaf.{extension}" + return_type = "" if language == "javascript" else ": number" + write_text( + repo_dir / changed_path, + f"export function leaf(){return_type} {{ return 1; }}\n", + ) + for index in range(dependent_files): + relative = f"caller_{index:04d}.{extension}" + import_suffix = ".js" if language == "javascript" else "" + write_text( + repo_dir / relative, + f"import {{ leaf }} from './leaf{import_suffix}';\n\n" + f"export function caller{index:04d}(){return_type} " + f"{{ return leaf() + {index}; }}\n", + ) + dependent_paths.append(relative) + elif language == "php": + changed_path = "Leaf.php" + write_text( + repo_dir / changed_path, + " i32 { 1 }\n") + modules = ["mod leaf;"] + for index in range(dependent_files): + module = f"caller_{index:04d}" + relative = f"{module}.rs" + modules.append(f"mod {module};") + write_text( + repo_dir / relative, + "use crate::leaf::leaf_value;\n" + f"pub fn caller_{index:04d}() -> i32 {{ leaf_value() + {index} }}\n", + ) + dependent_paths.append(relative) + write_text(repo_dir / "lib.rs", "\n".join(modules) + "\n") + else: + raise ValueError(f"unsupported frontier language: {language}") + resolver_language = "c" if language == "c_header" else language + metadata = { + "source": "synthetic_inbound_frontier", + "language": language, + "cross_file_resolver_language": resolver_language, + "changed_path": changed_path, + "requested_inbound_dependents": dependent_files, + "dependent_paths": dependent_paths, + } + if resolver_language in SCOPED_EXACT_FRONTIER_LANGUAGES: + metadata.update( + { + "incremental_contract": "exact_frontier", + "expected_minimum_affected_files": dependent_files + 1, + } + ) + else: + metadata.update( + { + "incremental_contract": "safe_full_rebuild", + "expected_publish_kind": PUBLISH_FULL, + "expected_reason": "scoped_lsp_gap", + } + ) + return metadata + + +def mutate_inbound_frontier_repo(repo_dir: Path, language: str) -> list[str]: + if language == "go": + changed_path = "leaf.go" + content = ( + "package frontier\n\n" + "func Leaf() int { return 2 }\n\n" + "func LeafExtra() int { return Leaf() + 1 }\n" + ) + elif language == "python": + changed_path = "leaf.py" + content = ( + "def leaf():\n return 2\n\ndef leaf_extra():\n return leaf() + 1\n" + ) + elif language == "c_header": + changed_path = "shared.h" + content = ( + "#ifndef SHARED_H\n" + "#define SHARED_H\n" + "static int shared_value(void) { return 2; }\n" + "static int shared_extra(void) { return shared_value() + 1; }\n" + "#endif\n" + ) + elif language in {"cpp", "cuda"}: + header_ext = "hpp" if language == "cpp" else "cuh" + changed_path = f"shared.{header_ext}" + content = ( + "inline int shared_value() { return 2; }\n" + "inline int shared_extra() { return shared_value() + 1; }\n" + ) + elif language in {"javascript", "typescript", "tsx"}: + extension = {"javascript": "js", "typescript": "ts", "tsx": "tsx"}[language] + changed_path = f"leaf.{extension}" + return_type = "" if language == "javascript" else ": number" + content = ( + f"export function leaf(){return_type} {{ return 2; }}\n" + f"export function leafExtra(){return_type} {{ return leaf() + 1; }}\n" + ) + elif language == "php": + changed_path = "Leaf.php" + content = ( + " i32 { 2 }\n" + "pub fn leaf_extra() -> i32 { leaf_value() + 1 }\n" + ) + else: + raise ValueError(f"unsupported frontier language: {language}") + write_text(repo_dir / changed_path, content) + return [changed_path] + + +def command_result( + cmd: list[str], + env: dict[str, str], + timeout: int, + cwd: Path | None = None, +) -> tuple[subprocess.CompletedProcess[str], float]: + start = now_ms() + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + env=env, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return proc, now_ms() - start + + +def parse_list_project_counts(raw: str) -> list[int]: + """Parse a strictly increasing positive scaling series.""" + items = raw.split(",") if raw else [] + try: + counts = [int(item.strip()) for item in items if item.strip()] + except ValueError as exc: + raise ValueError( + "list project counts must be comma-separated integers" + ) from exc + if not counts or any(count <= 0 for count in counts): + raise ValueError("list project counts must contain positive integers") + if any(left >= right for left, right in pairwise(counts)): + raise ValueError("list project counts must be strictly increasing") + return counts + + +def list_project_fixture_budget( + *, + seed_bytes: int, + maximum_projects: int, + maximum_fixture_mb: int, + disk_free_bytes: int, +) -> dict[str, Any]: + """Return a deterministic disk gate before cloning list-project fixtures.""" + if min(seed_bytes, maximum_projects, maximum_fixture_mb, disk_free_bytes) <= 0: + raise ValueError("list-project fixture budget inputs must be positive") + mib = 1024 * 1024 + projected_bytes = seed_bytes * maximum_projects + cap_bytes = maximum_fixture_mb * mib + reserved_bytes = max( + LIST_PROJECT_DISK_RESERVE_BYTES, + math.ceil(disk_free_bytes * LIST_PROJECT_DISK_RESERVE_FRACTION), + ) + available_after_reserve = max(0, disk_free_bytes - reserved_bytes) + reason = "" + if projected_bytes > cap_bytes: + reason = "projected fixture exceeds configured cap" + elif projected_bytes > available_after_reserve: + reason = "projected fixture violates free-space reserve" + return { + "passed": not reason, + "reason": reason or None, + "seed_bytes": seed_bytes, + "maximum_projects": maximum_projects, + "projected_fixture_bytes": projected_bytes, + "configured_cap_bytes": cap_bytes, + "disk_free_bytes": disk_free_bytes, + "reserved_free_bytes": reserved_bytes, + "available_after_reserve_bytes": available_after_reserve, + } + + +def text_tail(text: str, max_lines: int = FAILURE_TAIL_LINES) -> list[str]: + lines = text.splitlines() + return lines[-max_lines:] + + +def failure_artifact_dir(env: dict[str, str]) -> Path: + cache_dir = env.get("CBM_CACHE_DIR") + if cache_dir: + return Path(cache_dir).expanduser().parent / FAILURE_ARTIFACT_DIRNAME + return Path(tempfile.gettempdir()) / FAILURE_FALLBACK_DIRNAME + + +def command_failure( + label: str, + cmd: list[str], + env: dict[str, str], + proc: subprocess.CompletedProcess[str], + elapsed_ms: float, +) -> BenchmarkCommandError: + safe_label = re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("_") or "command" + stamp = datetime.now(timezone.utc).strftime(FAILURE_TIMESTAMP_FORMAT) + prefix = failure_artifact_dir(env) / f"{stamp}-{safe_label}" + stdout_path = Path(f"{prefix}.stdout.txt") + stderr_path = Path(f"{prefix}.stderr.txt") + meta_path = Path(f"{prefix}.meta.json") + + write_text(stdout_path, proc.stdout) + write_text(stderr_path, proc.stderr) + detail: dict[str, Any] = { + "label": label, + "returncode": proc.returncode, + "elapsed_ms": round(elapsed_ms, 3), + "stdout_bytes": len(proc.stdout.encode("utf-8")), + "stderr_bytes": len(proc.stderr.encode("utf-8")), + "stdout_tail": text_tail(proc.stdout), + "stderr_tail": text_tail(proc.stderr), + "artifacts": { + "stdout": str(stdout_path), + "stderr": str(stderr_path), + "meta": str(meta_path), + }, + } + write_text( + meta_path, + json.dumps({"cmd": cmd, **detail}, indent=2, sort_keys=True) + "\n", + ) + return BenchmarkCommandError( + f"{label} failed with rc={proc.returncode}; artifacts={detail['artifacts']}", + detail, + ) + + +def record_report_error(report: dict[str, Any], exc: Exception) -> None: + report["error"] = f"{type(exc).__name__}: {exc}" + if isinstance(exc, BenchmarkCommandError): + report["error_detail"] = exc.detail + + +def command_stdout(cmd: list[str], timeout: int, cwd: Path | None = None) -> str: + proc, _ = command_result(cmd, dict(os.environ), timeout, cwd) + if proc.returncode != 0: + rendered = " ".join(cmd) + raise RuntimeError(f"{rendered} failed: {proc.stderr.strip()}") + return proc.stdout.strip() + + +def command_stdout_bytes( + cmd: list[str], timeout: int, cwd: Path | None = None +) -> bytes: + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + env=dict(os.environ), + capture_output=True, + timeout=timeout, + check=False, + ) + if proc.returncode != 0: + rendered = " ".join(cmd) + stderr = proc.stderr.decode("utf-8", "replace").strip() + raise RuntimeError(f"{rendered} failed: {stderr}") + return proc.stdout + + +def append_text(path: Path, text: str) -> None: + current = path.read_text(encoding="utf-8") + path.write_text(current + text, encoding="utf-8") + + +def unwrap_cli_json(stdout: str) -> dict[str, Any]: + outer = json.loads(stdout) + if "content" in outer: + return json.loads(outer["content"][0]["text"]) + return outer + + +def unwrap_mcp_result(response: dict[str, Any]) -> dict[str, Any]: + result = response.get("result", {}) + if "content" in result: + return json.loads(result["content"][0]["text"]) + return result + + +def cli_result_text(stdout: str) -> str: + outer = json.loads(stdout) + if "content" in outer: + return str(outer["content"][0]["text"]) + return json.dumps(outer, separators=(",", ":"), sort_keys=True) + + +def mcp_result_text(response: dict[str, Any]) -> str: + result = response.get("result", {}) + content = result.get("content") + if isinstance(content, list): + # The server may prepend a one-shot update notice as a separate text block. + # The tool payload remains the final text block; selecting it preserves both + # JSON and default TOON responses without matching notice wording. + for item in reversed(content): + if ( + isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ): + return item["text"] + return json.dumps(result, separators=(",", ":"), sort_keys=True) + + +TOKEN_ESTIMATOR = "utf8_bytes_div_4_ceil" + + +def canonical_response_bytes(data: dict[str, Any]) -> bytes: + """Serialize the tool payload independently of CLI/MCP envelopes.""" + return json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def estimate_response_tokens(payload: bytes) -> int: + """Return a deterministic, dependency-free byte/4 token estimate.""" + return (len(payload) + 3) // 4 + + +def build_search_projection_observation( + variant: str, + data: dict[str, Any], + mcp_envelope_bytes: int, + elapsed_ms: float, + transport_survived: bool, +) -> dict[str, Any]: + results = data.get("results") + typed_results = ( + [item for item in results if isinstance(item, dict)] + if isinstance(results, list) + else [] + ) + result_keys = {str(key) for item in typed_results for key in item} + property_fields = sorted(result_keys - SEARCH_PROJECTION_CORE_FIELDS) + internal_fields = sorted(result_keys & SEARCH_PROJECTION_INTERNAL_FIELDS) + qualified_names = [ + str(item["qualified_name"]) + for item in typed_results + if isinstance(item.get("qualified_name"), str) + ] + payload = canonical_response_bytes(data) + return { + "variant": variant, + "returned_count": len(typed_results), + "qualified_names": qualified_names, + "property_fields": property_fields, + "internal_fields": internal_fields, + "response_bytes": len(payload), + "response_token_estimate": estimate_response_tokens(payload), + "mcp_envelope_bytes": mcp_envelope_bytes, + "elapsed_ms": round(elapsed_ms, 3), + "transport_survived": transport_survived, + "passed": isinstance(results, list) + and not internal_fields + and transport_survived, + } + + +def process_rss_kb(pid: int) -> int | None: + """Read resident memory after a call; this is not a peak-RSS measurement.""" + try: + proc = subprocess.run( + ["ps", "-o", "rss=", "-p", str(pid)], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if proc.returncode != 0: + return None + return int(proc.stdout.strip()) + except (OSError, ValueError, subprocess.TimeoutExpired): + return None + + +def tool_schema_sha256(tool: dict[str, Any]) -> str: + schema = tool.get("inputSchema") + payload = json.dumps(schema, separators=(",", ":"), sort_keys=True).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def tool_contract_sha256(tool: dict[str, Any]) -> str: + """Hash every MCP tools/list field that affects client discovery and invocation.""" + contract = { + key: tool.get(key) + for key in ( + "name", + "title", + "description", + "inputSchema", + "outputSchema", + "annotations", + ) + } + payload = json.dumps(contract, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) + return hashlib.sha256(payload).hexdigest() + + +def tool_schema_properties(tool: dict[str, Any] | None) -> set[str]: + if not isinstance(tool, dict): + return set() + schema = tool.get("inputSchema") + properties = schema.get("properties") if isinstance(schema, dict) else None + return set(map(str, properties)) if isinstance(properties, dict) else set() + + +def tool_schema_required(tool: dict[str, Any] | None) -> set[str]: + if not isinstance(tool, dict): + return set() + schema = tool.get("inputSchema") + required = schema.get("required") if isinstance(schema, dict) else None + return set(map(str, required)) if isinstance(required, list) else set() + + +def schema_validation_shape(value: Any) -> Any: + """Remove documentation-only JSON Schema fields while retaining validation semantics.""" + if isinstance(value, dict): + return { + str(key): schema_validation_shape(item) + for key, item in sorted(value.items()) + if key not in {"description", "title", "$comment", "examples"} + } + if isinstance(value, list): + return [schema_validation_shape(item) for item in value] + return value + + +def compare_mcp_tool_surfaces( + pre_reveal: list[dict[str, Any]], + post_reveal: list[dict[str, Any]], + classic: list[dict[str, Any]], + *, + pre_dispatch: dict[str, bool], + list_changed_observed: bool, +) -> dict[str, Any]: + """Compare discovery and callable coverage without conflating hidden with absent.""" + pre_by_name = { + str(tool.get("name")): tool for tool in pre_reveal if tool.get("name") + } + post_by_name = { + str(tool.get("name")): tool for tool in post_reveal if tool.get("name") + } + classic_by_name = { + str(tool.get("name")): tool for tool in classic if tool.get("name") + } + classic_names = set(classic_by_name) + advertised_pre = sorted(classic_names & set(pre_by_name)) + hidden_pre = sorted(classic_names - set(pre_by_name)) + dispatch_recognized_pre = sorted( + name for name in classic_names if pre_dispatch.get(name) is True + ) + missing_post = sorted(classic_names - set(post_by_name)) + schema_mismatches = sorted( + name + for name in classic_names & set(post_by_name) + if tool_schema_sha256(classic_by_name[name]) + != tool_schema_sha256(post_by_name[name]) + ) + name_parity = not missing_post + schema_parity = name_parity and not schema_mismatches + contract_mismatches = sorted( + name + for name in classic_names & set(post_by_name) + if tool_contract_sha256(classic_by_name[name]) + != tool_contract_sha256(post_by_name[name]) + ) + contract_parity = name_parity and not contract_mismatches + dispatch_parity = len(dispatch_recognized_pre) == len(classic_names) and bool( + classic_names + ) + alias_streamlined = pre_by_name.get("get_code") + alias_classic = classic_by_name.get("get_code_snippet") + streamlined_properties = tool_schema_properties(alias_streamlined) + classic_properties = tool_schema_properties(alias_classic) + streamlined_required = tool_schema_required(alias_streamlined) + classic_required = tool_schema_required(alias_classic) + alias = { + "streamlined_name": "get_code", + "classic_name": "get_code_snippet", + "both_advertised_in_compared_surfaces": bool( + alias_streamlined and alias_classic + ), + "schema_equal": bool(alias_streamlined and alias_classic) + and tool_schema_sha256(alias_streamlined) == tool_schema_sha256(alias_classic), + "validation_shape_equal": bool(alias_streamlined and alias_classic) + and schema_validation_shape(alias_streamlined.get("inputSchema")) + == schema_validation_shape(alias_classic.get("inputSchema")), + "property_names_equal": streamlined_properties == classic_properties, + "shared_properties": sorted(streamlined_properties & classic_properties), + "streamlined_only_properties": sorted( + streamlined_properties - classic_properties + ), + "classic_only_properties": sorted(classic_properties - streamlined_properties), + "streamlined_required": sorted(streamlined_required), + "classic_required": sorted(classic_required), + "required_names_equal": streamlined_required == classic_required, + } + capability_parity: list[dict[str, Any]] = [] + for ( + capability, + outcome, + classic_required_names, + streamlined_names, + ) in MCP_CAPABILITY_SURFACES: + classic_required = set(classic_required_names) + if not classic_required.issubset(classic_names): + continue + streamlined_required = set(streamlined_names) + pre_advertised = bool(streamlined_required) and streamlined_required.issubset( + pre_by_name + ) + pre_callable = pre_advertised or all( + pre_dispatch.get(name) is True for name in classic_required + ) + capability_parity.append( + { + "capability": capability, + "outcome": outcome, + "classic_tools": sorted(classic_required), + "streamlined_pre_reveal_tools": sorted(streamlined_required), + "classic_advertised": True, + "streamlined_pre_reveal_advertised": pre_advertised, + "streamlined_pre_reveal_callable": pre_callable, + "streamlined_post_reveal_advertised": classic_required.issubset( + post_by_name + ), + "evidence": "tools/list contracts and bounded handler-recognition probes", + } + ) + capability_parity_passed = bool(capability_parity) and all( + item["streamlined_pre_reveal_callable"] + and item["streamlined_post_reveal_advertised"] + for item in capability_parity + ) + return { + "comparison_scope": { + "advertised_parity": ( + "tool names and full tools/list contract hashes: title, description, " + "input/output schemas, and annotations" + ), + "dispatch_parity": ( + "handler recognition from bounded empty-argument calls; this does not claim " + "end-to-end behavioral equality" + ), + "capability_parity": ( + "user-outcome mapping from advertised contracts and bounded handler recognition; " + "functional quality is measured by separate capability fixtures" + ), + }, + "pre_reveal": { + "advertised_classic_tools": f"{len(advertised_pre)}/{len(classic_names)}", + "advertised_classic_tool_names": advertised_pre, + "intentionally_hidden_classic_tools": hidden_pre, + "dispatch_recognized_classic_tools": ( + f"{len(dispatch_recognized_pre)}/{len(classic_names)}" + ), + "dispatch_recognized_classic_tool_names": dispatch_recognized_pre, + "classic_dispatch_parity": dispatch_parity, + "get_code_alias": alias, + }, + "post_reveal": { + "classic_name_parity": name_parity, + "missing_classic_tools": missing_post, + "classic_schema_parity": schema_parity, + "schema_mismatches": schema_mismatches, + "classic_contract_parity": contract_parity, + "contract_mismatches": contract_mismatches, + "tools_list_changed_observed": list_changed_observed, + }, + "capability_parity": capability_parity, + "passed": ( + dispatch_parity + and name_parity + and schema_parity + and contract_parity + and capability_parity_passed + and list_changed_observed + ), + } + + +def capture_tool_surface( + client: "McpClient", +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + start = now_ms() + response = client._request("tools/list", {}) + elapsed_ms = now_ms() - start + result = response.get("result") + tools = result.get("tools") if isinstance(result, dict) else None + if not isinstance(tools, list) or not all(isinstance(tool, dict) for tool in tools): + raise RuntimeError("MCP tools/list did not return an object array") + typed_tools = list(tools) + payload = json.dumps(response, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) + return ( + { + "tool_count": len(typed_tools), + "tool_names": [str(tool.get("name")) for tool in typed_tools], + "input_schema_sha256": { + str(tool.get("name")): tool_schema_sha256(tool) + for tool in typed_tools + if tool.get("name") + }, + "list_elapsed_ms": round(elapsed_ms, 3), + "response_bytes": len(payload), + "response_token_estimate": estimate_response_tokens(payload), + "token_estimator": TOKEN_ESTIMATOR, + }, + typed_tools, + ) + + +class McpClient: + def __init__(self, binary: Path, env: dict[str, str], timeout: int) -> None: + self.binary = binary + self.env = env + self.timeout = timeout + self.next_id = 1 + self.notifications: list[dict[str, Any]] = [] + self.stderr_lines: list[str] = [] + self.stderr_lock = threading.Lock() + self.stdout_queue: queue.Queue[str | None] = queue.Queue() + self.proc: subprocess.Popen[str] | None = None + self.stdout_thread: threading.Thread | None = None + self.stderr_thread: threading.Thread | None = None + self.cleanup: dict[str, Any] = { + "process_reaped": False, + "reader_threads_reaped": False, + "returncode": None, + } + + def __enter__(self) -> "McpClient": + self.proc = subprocess.Popen( + [str(self.binary)], + env=self.env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + self.stdout_thread = threading.Thread(target=self._read_stdout, daemon=True) + self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + self.stdout_thread.start() + self.stderr_thread.start() + self._initialize() + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + if not self.proc: + return + proc = self.proc + process_reaped = False + try: + try: + if proc.stdin: + proc.stdin.close() + proc.wait(timeout=5) + process_reaped = True + except subprocess.TimeoutExpired: + proc.terminate() + try: + proc.wait(timeout=5) + process_reaped = True + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + process_reaped = True + finally: + reader_threads = tuple( + thread for thread in (self.stdout_thread, self.stderr_thread) if thread + ) + for thread in reader_threads: + thread.join(timeout=5) + alive_threads = [thread for thread in reader_threads if thread.is_alive()] + for stream in (proc.stdout, proc.stderr): + if stream: + stream.close() + for thread in alive_threads: + thread.join(timeout=1) + readers_still_alive = any(thread.is_alive() for thread in alive_threads) + self.cleanup = { + "process_reaped": process_reaped, + "reader_threads_reaped": not readers_still_alive, + "returncode": getattr(proc, "returncode", None), + } + self.proc = None + self.stdout_thread = None + self.stderr_thread = None + if readers_still_alive and exc_type is None: + raise RuntimeError("MCP reader thread did not stop after process exit") + + def _read_stdout(self) -> None: + assert self.proc and self.proc.stdout + for line in self.proc.stdout: + self.stdout_queue.put(line) + self.stdout_queue.put(None) + + def _read_stderr(self) -> None: + assert self.proc and self.proc.stderr + for line in self.proc.stderr: + with self.stderr_lock: + self.stderr_lines.append(line.rstrip("\n")) + + def _stderr_mark(self) -> int: + with self.stderr_lock: + return len(self.stderr_lines) + + def _stderr_since(self, mark: int) -> str: + with self.stderr_lock: + return "\n".join(self.stderr_lines[mark:]) + + def _send(self, message: dict[str, Any]) -> None: + if not self.proc or not self.proc.stdin: + raise RuntimeError("MCP server is not running") + self.proc.stdin.write(json.dumps(message, separators=(",", ":")) + "\n") + self.proc.stdin.flush() + + def _request( + self, method: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + req_id = self.next_id + self.next_id += 1 + message: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method} + if params is not None: + message["params"] = params + self._send(message) + + deadline = time.monotonic() + self.timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"MCP request timed out: {method}") + line = self.stdout_queue.get(timeout=remaining) + if line is None: + raise RuntimeError(f"MCP server exited before response: {method}") + try: + response = json.loads(line) + except json.JSONDecodeError as exc: + raise RuntimeError(f"non-JSON MCP stdout line: {line[:200]!r}") from exc + if "id" not in response and isinstance(response.get("method"), str): + self.notifications.append(response) + continue + if response.get("id") == req_id: + if "error" in response: + raise RuntimeError(f"MCP request failed: {response['error']}") + return response + + def _notification(self, method: str, params: dict[str, Any] | None = None) -> None: + message: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + message["params"] = params + self._send(message) + + def _initialize(self) -> None: + self._request( + "initialize", + { + "protocolVersion": MCP_INIT_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "cbm-incr-speed", "version": "1.0"}, + }, + ) + self._notification("notifications/initialized") + + def call_tool( + self, name: str, arguments: dict[str, Any] + ) -> tuple[dict[str, Any], str, int, float]: + text, stderr, stdout_bytes, elapsed_ms = self.call_tool_text(name, arguments) + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"MCP tool {name} returned non-JSON text: {text[:200]!r}" + ) from exc + return data, stderr, stdout_bytes, elapsed_ms + + def call_tool_text( + self, name: str, arguments: dict[str, Any] + ) -> tuple[str, str, int, float]: + mark = self._stderr_mark() + start = now_ms() + response = self._request("tools/call", {"name": name, "arguments": arguments}) + elapsed_ms = now_ms() - start + stderr = self._stderr_since(mark) + stdout_bytes = len(json.dumps(response, separators=(",", ":")).encode("utf-8")) + return mcp_result_text(response), stderr, stdout_bytes, elapsed_ms + + +def run_mcp_surface_parity( + args: argparse.Namespace, binary: Path +) -> tuple[dict[str, Any], int]: + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix="cbm-mcp-surface-")) + ) + work_root.mkdir(parents=True, exist_ok=True) + report: dict[str, Any] = { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "binary": str(binary), + "binary_metadata": binary_metadata(binary), + "mode": "mcp_surface_parity", + "protocol_version": MCP_INIT_PROTOCOL_VERSION, + "work_root": str(work_root), + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + } + exit_code = 1 + try: + base_env = build_env(work_root / "cache", args.product_environment) + base_env["CBM_AUTO_INDEX"] = "false" + + classic_env = dict(base_env) + classic_env["CBM_TOOL_MODE"] = "classic" + with McpClient(binary, classic_env, args.timeout) as classic_client: + classic_summary, classic_tools = capture_tool_surface(classic_client) + classic_summary["lifecycle"] = dict(classic_client.cleanup) + + streamlined_env = dict(base_env) + streamlined_env["CBM_TOOL_MODE"] = "streamlined" + with McpClient(binary, streamlined_env, args.timeout) as streamlined_client: + pre_summary, pre_tools = capture_tool_surface(streamlined_client) + pre_dispatch: dict[str, bool] = {} + dispatch_bytes: dict[str, int] = {} + for tool in classic_tools: + name = str(tool.get("name") or "") + if not name: + continue + text, _, response_bytes, _ = streamlined_client.call_tool_text(name, {}) + pre_dispatch[name] = "unknown tool" not in text.lower() + dispatch_bytes[name] = response_bytes + streamlined_client.call_tool_text("_hidden_tools", {}) + post_summary, post_tools = capture_tool_surface(streamlined_client) + list_changed_observed = any( + item.get("method") == "notifications/tools/list_changed" + for item in streamlined_client.notifications + ) + pre_summary["lifecycle"] = dict(streamlined_client.cleanup) + post_summary["lifecycle"] = dict(streamlined_client.cleanup) + + comparison = compare_mcp_tool_surfaces( + pre_tools, + post_tools, + classic_tools, + pre_dispatch=pre_dispatch, + list_changed_observed=list_changed_observed, + ) + pre_summary["classic_dispatch_recognized"] = pre_dispatch + pre_summary["dispatch_response_bytes"] = dispatch_bytes + lifecycle_passed = all( + bool(summary.get("lifecycle", {}).get("process_reaped")) + and bool(summary.get("lifecycle", {}).get("reader_threads_reaped")) + for summary in (classic_summary, pre_summary, post_summary) + ) + comparison["lifecycle_passed"] = lifecycle_passed + comparison["passed"] = bool(comparison["passed"]) and lifecycle_passed + report.update( + { + "surfaces": { + "streamlined_pre_reveal": pre_summary, + "streamlined_post_reveal": post_summary, + "classic": classic_summary, + }, + "comparison": comparison, + "derived": {"passed": comparison["passed"]}, + } + ) + exit_code = 0 if comparison["passed"] else 1 + except Exception as exc: + record_report_error(report, exc) + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + return report, exit_code + + +def run_list_projects_scaling( + args: argparse.Namespace, binary: Path +) -> tuple[dict[str, Any], int]: + counts = parse_list_project_counts(args.list_project_counts) + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix="cbm-list-projects-scaling-")) + ) + cache_dir = work_root / "cache" + seed_repo = work_root / "seed-repo" + cache_dir.mkdir(parents=True, exist_ok=True) + seed_repo.mkdir(parents=True, exist_ok=True) + generated_at = datetime.now(timezone.utc) + metadata = binary_metadata(binary) + run_id = ( + f"list-projects-{generated_at.strftime(FAILURE_TIMESTAMP_FORMAT)}-" + f"{metadata['sha256'][:12]}-{os.getpid()}" + ) + report: dict[str, Any] = { + "schema_version": 1, + "run_id": run_id, + "generated_at_utc": generated_at.isoformat(), + "binary": str(binary), + "binary_metadata": metadata, + "source_revision": git_metadata( + Path(__file__).resolve().parents[1], args.timeout + ), + "mode": "list_projects_scaling", + "parameters": { + "project_counts": counts, + "maximum_fixture_mb": args.list_project_fixture_max_mb, + "timeout_seconds": args.timeout, + "process_isolation": "fresh_mcp_server_per_count", + "seed_index_mode": "fast", + "seed_config_profile": CONFIG_PROFILE_MINIMAL_INDEXING, + "list_projects_arguments": {"all": True}, + "inventory_mode": "explicit_full_compatibility", + "token_estimator": TOKEN_ESTIMATOR, + "rss_measurement": "post_call_resident_kb_not_peak", + }, + "work_root": str(work_root), + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + "observations": [], + "completion": {"status": "running"}, + } + exit_code = 1 + try: + create_repo(seed_repo, 1, 1) + env = build_env(cache_dir, args.product_environment) + env.pop("CBM_PROFILE", None) + apply_config_overrides( + binary, env, CONFIG_PROFILES[CONFIG_PROFILE_MINIMAL_INDEXING], args.timeout + ) + with McpClient(binary, env, args.timeout) as client: + seed_result, _, _, _ = client.call_tool( + "index_repository", + {**index_tool_arguments(seed_repo, "fast"), "auto_index_deps": False}, + ) + seed_db = find_project_db(cache_dir) + seed_project = str(seed_result.get("project") or seed_db.stem) + disk = shutil.disk_usage(work_root) + budget = list_project_fixture_budget( + seed_bytes=seed_db.stat().st_size, + maximum_projects=counts[-1], + maximum_fixture_mb=args.list_project_fixture_max_mb, + disk_free_bytes=disk.free, + ) + report["fixture"] = { + "seed_project": seed_project, + "seed_db": str(seed_db), + "budget": budget, + } + if not budget["passed"]: + raise RuntimeError(str(budget["reason"])) + + created_projects = 1 + for requested_count in counts: + for fixture_index in range(created_projects, requested_count): + project = f"list-project-{fixture_index:06d}" + destination = cache_dir / f"{project}{PROJECT_DB_SUFFIX}" + root_path = work_root / "roots" / project + clone_list_project_db(seed_db, destination, project, str(root_path)) + created_projects = requested_count + + client = McpClient(binary, env, args.timeout) + with client: + data, stderr, stdout_bytes, elapsed_ms = client.call_tool( + "list_projects", {"all": True} + ) + projects = data.get("projects") + returned_count = len(projects) if isinstance(projects, list) else None + transport_start = now_ms() + tools_response = client._request("tools/list", {}) + transport_probe_ms = now_ms() - transport_start + transport_survived = isinstance(tools_response.get("result"), dict) + rss_kb = process_rss_kb(client.proc.pid) if client.proc else None + server_reaped = ( + client.proc is None + and client.stdout_thread is None + and client.stderr_thread is None + ) + payload = canonical_response_bytes(data) + db_bytes = sum( + path.stat().st_size + for path in cache_dir.glob(f"*{PROJECT_DB_SUFFIX}") + if path.name != CONFIG_DB_NAME + ) + report["observations"].append( + { + "requested_projects": requested_count, + "returned_projects": returned_count, + "response_bytes": len(payload), + "response_token_estimate": estimate_response_tokens(payload), + "mcp_envelope_bytes": stdout_bytes, + "elapsed_ms": round(elapsed_ms, 3), + "post_call_rss_kb": rss_kb, + "transport_probe_ms": round(transport_probe_ms, 3), + "transport_survived": transport_survived, + "server_reaped": server_reaped, + "fixture_db_bytes": db_bytes, + "stderr_bytes": len(stderr.encode("utf-8")), + "passed": ( + returned_count == requested_count + and transport_survived + and server_reaped + ), + } + ) + + observations = report["observations"] + first = observations[0] + last = observations[-1] + count_delta = last["requested_projects"] - first["requested_projects"] + byte_delta = last["response_bytes"] - first["response_bytes"] + report["derived"] = { + "passed": all(item["passed"] for item in observations), + "largest_response_bytes": last["response_bytes"], + "largest_response_token_estimate": last["response_token_estimate"], + "incremental_response_bytes_per_project": ( + round(byte_delta / count_delta, 3) if count_delta > 0 else None + ), + "claim_boundary": ( + "Measures list_projects alone in isolated caches; does not attribute combined " + "multi-tool response size or claim peak RSS." + ), + } + exit_code = 0 if report["derived"]["passed"] else 1 + report["completion"] = {"status": "complete", "exit_code": exit_code} + except Exception as exc: + record_report_error(report, exc) + report["completion"] = {"status": "failed", "exit_code": 1} + exit_code = 1 + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + return report, exit_code + + +def run_search_projection( + args: argparse.Namespace, binary: Path +) -> tuple[dict[str, Any], int]: + if args.search_projection_results <= 0: + raise ValueError("search projection results must be positive") + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix="cbm-search-projection-")) + ) + cache_dir = work_root / "cache" + repo_dir = work_root / "repo" + cache_dir.mkdir(parents=True, exist_ok=True) + repo_dir.mkdir(parents=True, exist_ok=True) + generated_at = datetime.now(timezone.utc) + metadata = binary_metadata(binary) + report: dict[str, Any] = { + "schema_version": 1, + "run_id": ( + f"search-projection-{generated_at.strftime(FAILURE_TIMESTAMP_FORMAT)}-" + f"{metadata['sha256'][:12]}-{os.getpid()}" + ), + "generated_at_utc": generated_at.isoformat(), + "binary_metadata": metadata, + "source_revision": git_metadata( + Path(__file__).resolve().parents[1], args.timeout + ), + "mode": "search_projection", + "parameters": { + "requested_results": args.search_projection_results, + "format": "json", + "index_mode": "fast", + "config_profile": CONFIG_PROFILE_MINIMAL_INDEXING, + "process_isolation": "fresh_mcp_server_per_variant", + "token_estimator": TOKEN_ESTIMATOR, + "rss_measurement": "post_call_resident_kb_not_peak", + }, + "work_root": str(work_root), + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + "observations": [], + "completion": {"status": "running"}, + } + variants: tuple[tuple[str, dict[str, Any]], ...] = ( + ("compact_default", {}), + ("compact_true", {"compact": True}), + ( + "compact_selected_fields", + {"compact": True, "fields": ["complexity", "signature"]}, + ), + ("compact_false", {"compact": False}), + ) + exit_code = 1 + try: + file_count = min(4, args.search_projection_results) + funcs_per_file = math.ceil(args.search_projection_results / file_count) + create_repo(repo_dir, file_count, funcs_per_file) + env = build_env(cache_dir, args.product_environment) + env.pop("CBM_PROFILE", None) + apply_config_overrides( + binary, env, CONFIG_PROFILES[CONFIG_PROFILE_MINIMAL_INDEXING], args.timeout + ) + with McpClient(binary, env, args.timeout) as client: + index_result, _, _, _ = client.call_tool( + "index_repository", + {**index_tool_arguments(repo_dir, "fast"), "auto_index_deps": False}, + ) + project = str(index_result.get("project") or "") + if not project: + raise RuntimeError("projection fixture index response omitted project") + + for variant, overrides in variants: + arguments: dict[str, Any] = { + "project": project, + "name_pattern": "Func", + "limit": args.search_projection_results, + "sort_by": "name", + "include_dependencies": False, + "format": "json", + **overrides, + } + client = McpClient(binary, env, args.timeout) + with client: + data, _, envelope_bytes, elapsed_ms = client.call_tool( + "search_graph", arguments + ) + tools_response = client._request("tools/list", {}) + transport_survived = isinstance(tools_response.get("result"), dict) + rss_kb = process_rss_kb(client.proc.pid) if client.proc else None + server_reaped = ( + client.proc is None + and client.stdout_thread is None + and client.stderr_thread is None + ) + observation = build_search_projection_observation( + variant, data, envelope_bytes, elapsed_ms, transport_survived + ) + observation["post_call_rss_kb"] = rss_kb + observation["server_reaped"] = server_reaped + observation["passed"] = bool(observation["passed"] and server_reaped) + report["observations"].append(observation) + + observations = report["observations"] + baseline_names = observations[0]["qualified_names"] + by_variant = {item["variant"]: item for item in observations} + for observation in observations: + observation["identity_equal_to_default"] = ( + observation["qualified_names"] == baseline_names + ) + fields = set(observation["property_fields"]) + variant = observation["variant"] + if variant in {"compact_default", "compact_true"}: + projection_met = not fields + elif variant == "compact_selected_fields": + projection_met = bool(fields) and fields <= {"complexity", "signature"} + else: + projection_met = bool(fields) + observation["projection_contract_met"] = projection_met + observation["passed"] = bool( + observation["passed"] + and observation["identity_equal_to_default"] + and projection_met + ) + compact_bytes = int(by_variant["compact_true"]["response_bytes"]) + selected_bytes = int(by_variant["compact_selected_fields"]["response_bytes"]) + verbose_bytes = int(by_variant["compact_false"]["response_bytes"]) + report["derived"] = { + "passed": all(bool(item["passed"]) for item in observations), + "identity_parity": all( + bool(item["identity_equal_to_default"]) for item in observations + ), + "internal_fields_absent": all( + not item["internal_fields"] for item in observations + ), + "compact_bytes": compact_bytes, + "selected_fields_bytes": selected_bytes, + "non_compact_bytes": verbose_bytes, + "non_compact_over_compact_ratio": ( + round(verbose_bytes / compact_bytes, 3) if compact_bytes else None + ), + "projection_order_expected": compact_bytes + <= selected_bytes + <= verbose_bytes, + "claim_boundary": ( + "Measures response projection for identical ranked results after one small FAST " + "index; one latency observation per variant is descriptive only." + ), + } + report["derived"]["passed"] = bool( + report["derived"]["passed"] + and report["derived"]["projection_order_expected"] + ) + exit_code = 0 if report["derived"]["passed"] else 1 + report["completion"] = {"status": "complete", "exit_code": exit_code} + except Exception as exc: + record_report_error(report, exc) + report["completion"] = {"status": "failed", "exit_code": 1} + exit_code = 1 + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + return report, exit_code + + +def log_tail(stderr: str) -> list[str]: + lines = stderr.splitlines() + return lines[-LOG_TAIL_LINES:] + + +def log_has(stderr: str, marker: str) -> bool: + return marker in stderr + + +def response_publish_kind(data: dict[str, Any]) -> str: + publish_kind = data.get("publish_kind") + return publish_kind if isinstance(publish_kind, str) else "" + + +def response_publish_reason(data: dict[str, Any]) -> str: + publish_reason = data.get("publish_reason") + return publish_reason if isinstance(publish_reason, str) else "" + + +def response_freshness(data: dict[str, Any]) -> dict[str, Any] | None: + freshness = data.get("freshness") + return freshness if isinstance(freshness, dict) else None + + +def response_freshness_state(data: dict[str, Any]) -> str: + freshness = response_freshness(data) + if not freshness: + return "" + state = freshness.get("state") + return state if isinstance(state, str) else "" + + +def declared_stale_views(oracles: dict[str, Any]) -> list[str]: + """Return the sorted union of derived views explicitly reported stale.""" + views: set[str] = set() + for oracle in oracles.values(): + if not isinstance(oracle, dict): + continue + freshness = oracle.get("freshness") + if ( + not isinstance(freshness, dict) + or freshness.get("state") != "stale_with_warning" + ): + continue + stale = freshness.get("stale_views") + if isinstance(stale, list): + views.update(item for item in stale if isinstance(item, str) and item) + return sorted(views) + + +def persisted_stale_views(db_path: Path, project: str) -> list[str]: + """Read global derived-view state from the canonical SQLite freshness ledger.""" + uri = f"{db_path.resolve().as_uri()}?mode=ro" + try: + with closing(sqlite3.connect(uri, uri=True)) as con: + rows = con.execute( + "SELECT view_name FROM derived_view_state " + "WHERE project = ? AND status = 'stale' ORDER BY view_name", + (project,), + ) + return [str(row[0]) for row in rows if row[0]] + except sqlite3.OperationalError as exc: + if "no such table" in str(exc): + return [] + raise + + +def is_incremental_publish_kind(publish_kind: str) -> bool: + return publish_kind in { + PUBLISH_INCREMENTAL_NOOP, + PUBLISH_INCREMENTAL_EXACT, + PUBLISH_INCREMENTAL_OVERLAY, + PUBLISH_INCREMENTAL_CONTAINMENT, + } + + +def is_explicit_incremental_route( + publish_kind: str | None, reason: str | None = None +) -> bool: + return is_incremental_publish_kind(publish_kind or "") or bool(reason) + + +def parse_logged_elapsed_ms(stderr: str, marker: str) -> int | None: + return parse_log_int_field(stderr, marker, "elapsed_ms") + + +def parse_log_int_field(stderr: str, marker: str, field: str) -> int | None: + prefix = f"{field}=" + for line in stderr.splitlines(): + if marker not in line: + continue + for item in line.split(): + if item.startswith(prefix): + try: + return int(item.split("=", 1)[1]) + except ValueError: + return None + return None + + +def parse_log_max_int_field(stderr: str, marker: str, field: str) -> int | None: + prefix = f"{field}=" + maximum: int | None = None + for line in stderr.splitlines(): + if marker not in line: + continue + for item in line.split(): + if not item.startswith(prefix): + continue + try: + value = int(item.split("=", 1)[1]) + except ValueError: + continue + maximum = value if maximum is None else max(maximum, value) + return maximum + + +def parse_log_text_field(stderr: str, marker: str, field: str) -> str | None: + prefix = f"{field}=" + for line in reversed(stderr.splitlines()): + if marker not in line: + continue + for item in line.split(): + if item.startswith(prefix): + return item[len(prefix) :] + return None + + +def daemon_log_window(env: dict[str, str]) -> tuple[Path | None, int]: + cache_dir = env.get("CBM_CACHE_DIR") + path = Path(cache_dir) / DAEMON_LOG_RELATIVE_PATH if cache_dir else None + offset = path.stat().st_size if path and path.is_file() else 0 + return path, offset + + +def read_log_window(path: Path | None, offset: int) -> str: + if not path or not path.is_file(): + return "" + try: + current_size = path.stat().st_size + with path.open("rb") as stream: + stream.seek(offset if current_size >= offset else 0) + return stream.read().decode("utf-8", errors="replace") + except OSError: + return "" + + +def update_int_summary(summary: dict[str, int], value: int) -> None: + if not summary: + summary.update(first=value, last=value, min=value, max=value) + return + summary["last"] = value + summary["min"] = min(summary["min"], value) + summary["max"] = max(summary["max"], value) + + +def summarize_daemon_mem_census_since( + path: Path | None, offset: int +) -> dict[str, Any] | None: + """Summarize B new log bytes in O(B) time and O(L + F) memory. + + L is the longest log line and F is the fixed field count. Retained memory is + independent of the number of request samples, unlike storing every census. + """ + if not path or not path.is_file(): + return None + fields = ("rss_kb", "mi_area_kb", "mi_live_kb") + summaries: dict[str, dict[str, int]] = {field: {} for field in fields} + count = 0 + try: + current_size = path.stat().st_size + with path.open("rb") as stream: + stream.seek(offset if current_size >= offset else 0) + for raw_line in stream: + line = raw_line.decode("utf-8", errors="replace") + if "msg=mem.census" not in line or "at=mcp.request" not in line: + continue + parsed = { + field: parse_log_int_field(line, "msg=mem.census", field) + for field in fields + } + if any(value is None for value in parsed.values()): + continue + for field, value in parsed.items(): + update_int_summary(summaries[field], int(value)) + count += 1 + except OSError: + return None + if count == 0: + return None + for summary in summaries.values(): + summary["delta"] = summary["last"] - summary["first"] + return { + "count": count, + **summaries, + } + + +def summarize_daemon_profiles_since( + path: Path | None, + offset: int, + selected_profiles: tuple[tuple[str, str], ...], +) -> dict[str, dict[str, int | float]] | None: + """Summarize selected profile spans in O(B + R) time and O(L + P) memory. + + B is the number of new log bytes, R the matching records, L the longest log + line, and P the caller-bounded profile key count. Aggregating count/sum/range + avoids retaining every request duration while preserving exact arithmetic + means and extrema for server-versus-transport attribution. + """ + if not path or not path.is_file() or not selected_profiles: + return None + selected = set(selected_profiles) + summaries: dict[tuple[str, str], dict[str, int]] = {} + try: + current_size = path.stat().st_size + with path.open("rb") as stream: + stream.seek(offset if current_size >= offset else 0) + for raw_line in stream: + line = raw_line.decode("utf-8", errors="replace") + if "msg=prof" not in line: + continue + phase = parse_log_text_field(line, "msg=prof", "phase") + subphase = parse_log_text_field(line, "msg=prof", "sub") + profile = (phase, subphase) + if profile not in selected: + continue + elapsed_us = parse_log_int_field(line, "msg=prof", "us") + if elapsed_us is None or elapsed_us < 0: + continue + summary = summaries.setdefault(profile, {}) + if not summary: + summary.update( + count=1, + total_us=elapsed_us, + min_us=elapsed_us, + max_us=elapsed_us, + ) + else: + summary["count"] += 1 + summary["total_us"] += elapsed_us + summary["min_us"] = min(summary["min_us"], elapsed_us) + summary["max_us"] = max(summary["max_us"], elapsed_us) + except OSError: + return None + if not summaries: + return None + return { + f"{phase}/{subphase}": { + **profile_summary, + "mean_us": profile_summary["total_us"] / profile_summary["count"], + } + for phase, subphase in selected_profiles + if (profile_summary := summaries.get((phase, subphase))) is not None + } + + +def parse_exact_reason(stderr: str) -> str | None: + detail = parse_exact_route_detail(stderr) + reason = detail.get("reason") + return reason if isinstance(reason, str) and reason else None + + +def parse_exact_route_detail(stderr: str) -> dict[str, Any]: + detail: dict[str, Any] = { + "frontier_changed_files": parse_log_int_field( + stderr, LOG_MARKER_EXACT_FRONTIER, "changed" + ), + "frontier_expanded_files": parse_log_int_field( + stderr, LOG_MARKER_EXACT_FRONTIER, "expanded" + ), + "exact_done_files": parse_log_int_field(stderr, LOG_MARKER_EXACT_DONE, "files"), + "event": None, + "reason": None, + } + reason_markers = ( + (LOG_MARKER_EXACT_FALLBACK, "fallback"), + (LOG_MARKER_EXACT_DELETE_FALLBACK, "delete_fallback"), + (LOG_MARKER_EXACT_SKIP, "skip"), + ) + for line in stderr.splitlines(): + for marker, event in reason_markers: + prefix = f"msg={marker} reason=" + if prefix not in line: + continue + reason = line.split(prefix, 1)[1].split()[0] + detail["event"] = event + detail["reason"] = reason or None + return detail + if detail["exact_done_files"] is not None: + detail["event"] = "exact" + elif detail["frontier_expanded_files"] is not None: + detail["event"] = "frontier_observed" + return detail + + +def response_exact_delta(data: dict[str, Any]) -> dict[str, Any]: + exact_delta = data.get("exact_delta") + return exact_delta if isinstance(exact_delta, dict) else {} + + +def merge_exact_route_detail( + detail: dict[str, Any], + data: dict[str, Any], + publish_kind: str, + publish_reason: str, +) -> dict[str, Any]: + exact_delta = response_exact_delta(data) + field_map = { + "changed_paths": "frontier_changed_files", + "affected_paths": "frontier_expanded_files", + "published_paths": "exact_done_files", + } + for response_key, detail_key in field_map.items(): + value = exact_delta.get(response_key) + if detail.get(detail_key) is None and isinstance(value, int): + detail[detail_key] = value + if not detail.get("reason") and publish_reason: + detail["reason"] = publish_reason + if not detail.get("event"): + published = detail.get("exact_done_files") + if isinstance(published, int) and published > 0: + detail["event"] = "exact" + elif isinstance(published, int) and published == 0: + detail["event"] = "noop" + elif publish_reason: + detail["event"] = "fallback" + elif publish_kind == PUBLISH_INCREMENTAL_EXACT: + detail["event"] = "exact" + elif publish_kind == PUBLISH_INCREMENTAL_OVERLAY: + detail["event"] = "overlay" + elif publish_kind == PUBLISH_INCREMENTAL_NOOP: + detail["event"] = "noop" + return detail + + +def indexed_work_elapsed_ms(logged_elapsed_ms: dict[str, int | None]) -> int | None: + incremental_ms = logged_elapsed_ms.get("incremental_done") + if incremental_ms is not None: + return incremental_ms + return logged_elapsed_ms.get("pipeline_done") + + +def candidate_binary_identity(binary: Path) -> tuple[str, int, int]: + resolved = binary.resolve() + metadata = resolved.stat() + return str(resolved), metadata.st_mtime_ns, metadata.st_size + + +def config_spelling_mode(binary: Path, env: dict[str, str], timeout: int) -> str: + identity = candidate_binary_identity(binary) + with CONFIG_SPELLING_MODES_LOCK: + cached = CONFIG_SPELLING_MODES.get(identity) + if cached is not None: + return cached + + with tempfile.TemporaryDirectory( + prefix="cbm-config-spelling-probe-" + ) as cache_dir: + probe_env = dict(env) + probe_env["CBM_CACHE_DIR"] = cache_dir + canonical_cmd = [ + str(binary), + "config", + "set", + RANK_REFRESH_DEFAULT_SPELLINGS["canonical"]["key"], + RANK_REFRESH_DEFAULT_SPELLINGS["canonical"]["value"], + ] + canonical, canonical_elapsed_ms = command_result( + canonical_cmd, probe_env, timeout + ) + if canonical.returncode == 0: + CONFIG_SPELLING_MODES[identity] = CONFIG_SPELLING_CANONICAL + return CONFIG_SPELLING_CANONICAL + + pre_rename_cmd = [ + str(binary), + "config", + "set", + RANK_REFRESH_DEFAULT_SPELLINGS["historical"]["key"], + RANK_REFRESH_DEFAULT_SPELLINGS["historical"]["value"], + ] + pre_rename, pre_rename_elapsed_ms = command_result( + pre_rename_cmd, probe_env, timeout + ) + if pre_rename.returncode == 0: + CONFIG_SPELLING_MODES[identity] = CONFIG_SPELLING_PRE_RENAME + return CONFIG_SPELLING_PRE_RENAME + raise command_failure( + "config_spelling_probe", + pre_rename_cmd, + probe_env, + pre_rename, + pre_rename_elapsed_ms, + ) from command_failure( + "config_spelling_probe_canonical", + canonical_cmd, + probe_env, + canonical, + canonical_elapsed_ms, + ) + + +def run_config_set( + binary: Path, env: dict[str, str], key: str, value: str, timeout: int +) -> None: + canonical = (key, value) + if ( + canonical in PRE_RENAME_CONFIG_SPELLINGS + and config_spelling_mode(binary, env, timeout) == CONFIG_SPELLING_PRE_RENAME + ): + key, value = PRE_RENAME_CONFIG_SPELLINGS[canonical] + cmd = [str(binary), "config", "set", key, value] + proc, elapsed_ms = command_result(cmd, env, timeout) + if proc.returncode != 0: + raise command_failure(f"config_set_{key}", cmd, env, proc, elapsed_ms) + + +def parse_key_value_arguments(items: list[str], option: str) -> dict[str, str]: + values: dict[str, str] = {} + for item in items: + key, sep, value = item.partition("=") + if not sep or not key or not value: + raise SystemExit(f"error: {option} must be key=value, got {item!r}") + values[key] = value + return values + + +def parse_config_overrides(items: list[str]) -> dict[str, str]: + return parse_key_value_arguments(items, "--config") + + +def validate_product_environment( + values: dict[str, str], +) -> dict[str, str]: + for key in values: + if not key.startswith(PRODUCT_ENVIRONMENT_PREFIX): + raise SystemExit( + "error: --product-env key must start with " + f"{PRODUCT_ENVIRONMENT_PREFIX}, got {key!r}" + ) + if key in HARNESS_OWNED_PRODUCT_ENV: + raise SystemExit( + f"error: --product-env {key} is owned by the benchmark harness" + ) + return dict(values) + + +def parse_product_environment(items: list[str]) -> dict[str, str]: + return validate_product_environment( + parse_key_value_arguments(items, "--product-env") + ) + + +def resolve_config_overrides(profile: str, items: list[str]) -> dict[str, str]: + """Return one explicit benchmark profile plus higher-priority per-key overrides.""" + if profile not in CONFIG_PROFILES: + raise ValueError(f"unknown config profile: {profile}") + overrides = dict(CONFIG_PROFILES[profile]) + overrides.update(parse_config_overrides(items)) + return overrides + + +def index_tool_arguments(repo_dir: Path, index_mode: str) -> dict[str, str]: + if index_mode not in INDEX_MODES: + raise ValueError(f"unsupported index mode: {index_mode}") + return {"repo_path": str(repo_dir), "mode": index_mode} + + +def index_mode_capability_applicability(index_mode: str) -> dict[str, dict[str, Any]]: + if index_mode not in INDEX_MODES: + raise ValueError(f"unsupported index mode: {index_mode}") + available = {"applicable": True, "reason": f"available in {index_mode} mode"} + result = { + name: dict(available) + for name in ( + "rank", + "similarity", + "semantic_edges", + "git_history", + "http_links", + "dependencies", + ) + } + if index_mode == "fast": + result["similarity"] = { + "applicable": False, + "reason": "SIMILAR_TO generation requires full or moderate mode", + } + result["semantic_edges"] = { + "applicable": False, + "reason": "SEMANTICALLY_RELATED generation requires full or moderate mode", + } + return result + + +def apply_config_overrides( + binary: Path, env: dict[str, str], overrides: dict[str, str], timeout: int +) -> None: + for key, value in overrides.items(): + run_config_set(binary, env, key, value, timeout) + + +def apply_rank_refresh_override( + binary: Path, env: dict[str, str], policy: str, timeout: int +) -> bool: + """Apply an explicit rank policy while preserving each candidate's default.""" + if policy == RANK_REFRESH_CANDIDATE_DEFAULT: + return False + run_config_set(binary, env, "rank_refresh", policy, timeout) + return True + + +def build_index_result( + data: dict[str, Any], + stderr: str, + stdout_bytes: int, + elapsed_ms: float, + include_logs: bool, + measurement_diagnostics: str = "", +) -> dict[str, Any]: + measurement_log_markers: list[str] = [] + measurement_log_artifacts: list[dict[str, Any]] = [] + logfiles: list[str] = [] + supervisor_diagnostics = f"{stderr}\n{measurement_diagnostics}" + supervisor_log = parse_log_text_field( + supervisor_diagnostics, "index.supervisor.profile_log", "log" + ) + if supervisor_log: + logfiles.append(supervisor_log) + response_log = data.get("logfile") + if isinstance(response_log, str) and response_log and response_log not in logfiles: + logfiles.append(response_log) + for logfile in logfiles: + log_path = Path(logfile) + artifact_dir_value = os.environ.get(BENCHMARK_ARTIFACT_DIR_ENV) + if artifact_dir_value and log_path.is_file(): + measurement_log_artifacts.append( + archive_measurement_log(log_path, Path(artifact_dir_value)) + ) + try: + with log_path.open(encoding="utf-8", errors="replace") as stream: + for line in stream: + if any( + marker in line + for marker in ( + "msg=mem.phase", + "msg=pipeline.done", + "msg=incremental.done", + LOG_MARKER_DEP_AUTO_INDEX, + LOG_MARKER_RANK_REFRESH, + LOG_MARKER_INDEX_WORKER_TOTAL, + ) + ): + measurement_log_markers.append(line.rstrip("\n")) + if len(measurement_log_markers) >= 512: + break + except OSError: + continue + if measurement_log_markers: + break + measurement_text = "\n".join( + (stderr, measurement_diagnostics, *measurement_log_markers) + ) + elapsed_ms_int = int(elapsed_ms) + publish_kind = response_publish_kind(data) + logged_elapsed_ms = { + "pipeline_done": parse_logged_elapsed_ms( + measurement_text, LOG_MARKER_PIPELINE_DONE + ), + "incremental_done": parse_logged_elapsed_ms( + measurement_text, LOG_MARKER_INCREMENTAL_DONE + ), + } + indexed_ms = indexed_work_elapsed_ms(logged_elapsed_ms) + publish_reason = response_publish_reason(data) + exact_route_detail = merge_exact_route_detail( + parse_exact_route_detail(stderr), data, publish_kind, publish_reason + ) + freshness = response_freshness(data) + freshness_state = response_freshness_state(data) + peak_candidates = [ + parse_log_max_int_field(measurement_text, marker, "peak_mb") + for marker in ( + "mem.phase", + LOG_MARKER_PIPELINE_DONE, + LOG_MARKER_INCREMENTAL_DONE, + ) + ] + peak_rss_mb = max( + (value for value in peak_candidates if value is not None), default=None + ) + dependency_phase_ms = parse_log_int_field( + measurement_text, LOG_MARKER_DEP_AUTO_INDEX, "ms" + ) + rank_refresh_ms = parse_log_int_field( + measurement_text, LOG_MARKER_RANK_REFRESH, "ms" + ) + worker_elapsed_ms = parse_log_int_field( + measurement_text, LOG_MARKER_INDEX_WORKER_TOTAL, "ms" + ) + known_elapsed_ms = worker_elapsed_ms + if known_elapsed_ms is None: + known_components = [ + value + for value in (indexed_ms, dependency_phase_ms, rank_refresh_ms) + if value is not None + ] + known_elapsed_ms = sum(known_components) if known_components else None + process_overhead_ms = ( + max(0, elapsed_ms_int - known_elapsed_ms) + if known_elapsed_ms is not None + else None + ) + dependencies_indexed = data.get("dependencies_indexed") + dependency_packages = ( + dependencies_indexed + if isinstance(dependencies_indexed, int) and dependencies_indexed >= 0 + else None + ) + result: dict[str, Any] = { + "elapsed_ms": elapsed_ms_int, + "peak_rss_mb": peak_rss_mb, + "measurement_log_markers": measurement_log_markers, + "measurement_log_artifacts": measurement_log_artifacts, + "indexed_work_elapsed_ms": indexed_ms, + "worker_elapsed_ms": worker_elapsed_ms, + "process_overhead_ms": process_overhead_ms, + # Backwards-compatible field: now excludes every measured worker phase, + # not just the main pipeline. Prefer process_overhead_ms in new reports. + "unlogged_overhead_ms": process_overhead_ms, + "timing_components_ms": { + "main_index": indexed_ms, + "dependency_index": dependency_phase_ms, + "rank_refresh": rank_refresh_ms, + "worker_total": worker_elapsed_ms, + "cold_process_and_supervisor": process_overhead_ms, + }, + "response": data, + "publish_kind": publish_kind or None, + "freshness_state": freshness_state or None, + "freshness": freshness, + "stdout_bytes": stdout_bytes, + "dependency_indexing": { + "measurement_status": ( + "measured" + if dependency_phase_ms is not None or dependency_packages is not None + else "unknown" + ), + "phase_elapsed_ms": dependency_phase_ms, + "packages_indexed": dependency_packages, + }, + "markers": { + "incremental_exact_done": log_has(stderr, LOG_MARKER_EXACT_DONE) + or publish_kind == PUBLISH_INCREMENTAL_EXACT, + "incremental_done": log_has(stderr, LOG_MARKER_INCREMENTAL_DONE) + or is_incremental_publish_kind(publish_kind), + "pagerank_done": log_has(stderr, "pagerank.done"), + "pagerank_defer": log_has(stderr, "pagerank.defer"), + "full_route": log_has(stderr, "pipeline.route path=full") + or publish_kind == PUBLISH_FULL, + "incremental_route": log_has(stderr, "pipeline.route path=incremental") + or is_incremental_publish_kind(publish_kind), + }, + "logged_elapsed_ms": logged_elapsed_ms, + "exact_reason": publish_reason or exact_route_detail.get("reason"), + "exact_route_detail": exact_route_detail, + "stderr_tail": log_tail(stderr), + } + if include_logs: + result["stderr"] = stderr + return result + + +def run_index( + binary: Path, + env: dict[str, str], + repo_dir: Path, + timeout: int, + include_logs: bool, + index_mode: str = "fast", +) -> dict[str, Any]: + args = json.dumps(index_tool_arguments(repo_dir, index_mode)) + cmd = [str(binary), "cli", "--json", "index_repository", args] + proc, elapsed_ms = command_result( + cmd, + env, + timeout, + ) + if proc.returncode != 0: + raise command_failure("index_repository", cmd, env, proc, elapsed_ms) + data = unwrap_cli_json(proc.stdout) + return build_index_result( + data, proc.stderr, len(proc.stdout.encode("utf-8")), elapsed_ms, include_logs + ) + + +def run_index_mcp( + client: McpClient, + repo_dir: Path, + include_logs: bool, + index_mode: str = "fast", +) -> dict[str, Any]: + daemon_log, daemon_log_offset = daemon_log_window(client.env) + text, stderr, stdout_bytes, elapsed_ms = client.call_tool_text( + "index_repository", index_tool_arguments(repo_dir, index_mode) + ) + daemon_diagnostics = read_log_window(daemon_log, daemon_log_offset) + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + diagnostics = f"{stderr}\n{daemon_diagnostics}\n{text}" + worker_log = failure_worker_log_path(diagnostics) + measurement_log_artifacts: list[dict[str, Any]] = [] + archive_error = "" + artifact_dir_value = os.environ.get(BENCHMARK_ARTIFACT_DIR_ENV) + if worker_log and artifact_dir_value and worker_log.is_file(): + try: + # Streaming archive is O(L) time and O(1) working memory for an + # L-byte worker log. It runs only on the already-failing path, + # before the case finally-block removes its isolated cache. + measurement_log_artifacts.append( + archive_measurement_log(worker_log, Path(artifact_dir_value)) + ) + except OSError as archive_exc: + archive_error = f"{type(archive_exc).__name__}: {archive_exc}" + detail: dict[str, Any] = { + "label": "index_repository", + "elapsed_ms": round(elapsed_ms, 3), + "stdout_bytes": stdout_bytes, + "response_text_bytes": len(text.encode("utf-8")), + "response_text_tail": text_tail(text), + "stderr_tail": text_tail(stderr), + "daemon_log_tail": text_tail(daemon_diagnostics), + "worker_log_path": str(worker_log) if worker_log else "", + "measurement_log_artifacts": measurement_log_artifacts, + } + if archive_error: + detail["measurement_log_archive_error"] = archive_error + raise BenchmarkCommandError( + f"MCP tool index_repository returned non-JSON text; " + f"worker_log_archived={bool(measurement_log_artifacts)}", + detail, + ) from exc + return build_index_result( + data, + stderr, + stdout_bytes, + elapsed_ms, + include_logs, + measurement_diagnostics=daemon_diagnostics, + ) + + +def failure_worker_log_path(diagnostics: str) -> Path | None: + """Return the last worker-log path named by a failed supervisor response. + + Diagnostic scanning is O(D) time and O(1) auxiliary state for D bytes. The + human-readable inspect-log form is line-delimited, so paths containing + spaces remain intact; structured log output retains its established + whitespace-delimited representation. + """ + for line in reversed(diagnostics.splitlines()): + marker = "inspect log:" + if marker in line: + value = line.partition(marker)[2].strip().strip("'\"") + if value: + return Path(value) + if "index.supervisor." in line: + value = parse_log_text_field(line, "index.supervisor.", "log") + if value: + return Path(value) + return None + + +def build_tool_probe_result( + data: dict[str, Any], + stderr: str, + stdout_bytes: int, + elapsed_ms: float, + include_logs: bool, +) -> dict[str, Any]: + elapsed_ms_value = round(elapsed_ms, 3) + result: dict[str, Any] = { + "elapsed_ms": elapsed_ms_value, + "stdout_bytes": stdout_bytes, + "response_keys": sorted(str(key) for key in data), + "stderr_tail": log_tail(stderr), + } + if include_logs: + result["stderr"] = stderr + return result + + +def run_cli_tool_probe( + binary: Path, + env: dict[str, str], + tool_name: str, + timeout: int, + include_logs: bool, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any]: + encoded = json.dumps(arguments or {}, separators=(",", ":")) + cmd = [str(binary), "cli", "--json", tool_name, encoded] + proc, elapsed_ms = command_result(cmd, env, timeout) + if proc.returncode != 0: + raise command_failure(f"{tool_name}_probe", cmd, env, proc, elapsed_ms) + data = unwrap_cli_json(proc.stdout) + return build_tool_probe_result( + data, proc.stderr, len(proc.stdout.encode("utf-8")), elapsed_ms, include_logs + ) + + +def run_mcp_tool_probe( + client: McpClient, + tool_name: str, + include_logs: bool, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any]: + data, stderr, stdout_bytes, elapsed_ms = client.call_tool( + tool_name, arguments or {} + ) + return build_tool_probe_result(data, stderr, stdout_bytes, elapsed_ms, include_logs) + + +def build_tool_call_result( + data: dict[str, Any], + stderr: str, + stdout_bytes: int, + elapsed_ms: float, + include_logs: bool, + response_payload: bytes | None = None, +) -> dict[str, Any]: + quality_payload = canonical_response_bytes(data) + payload = response_payload if response_payload is not None else quality_payload + result: dict[str, Any] = { + "elapsed_ms": round(elapsed_ms, 3), + # Preserve the historical field while separating transport framing from + # the canonical payload used for cross-transport comparisons. + "stdout_bytes": stdout_bytes, + "transport_response_bytes": stdout_bytes, + "response_bytes": len(payload), + "response_token_estimate": estimate_response_tokens(payload), + "token_estimator": TOKEN_ESTIMATOR, + "response_encoding": "tool_default" + if response_payload is not None + else "canonical_json", + "quality_response_bytes": len(quality_payload), + "response": data, + "freshness_state": response_freshness_state(data) or None, + "freshness": response_freshness(data), + "stderr_tail": log_tail(stderr), + } + if include_logs: + result["stderr"] = stderr + return result + + +def run_cli_tool_call( + binary: Path, + env: dict[str, str], + tool_name: str, + arguments: dict[str, Any], + timeout: int, + include_logs: bool, +) -> dict[str, Any]: + encoded = json.dumps(arguments, separators=(",", ":")) + cmd = [str(binary), "cli", "--json", tool_name, encoded] + proc, elapsed_ms = command_result(cmd, env, timeout) + if proc.returncode != 0: + raise command_failure(f"{tool_name}_call", cmd, env, proc, elapsed_ms) + raw_payload = cli_result_text(proc.stdout).encode("utf-8") + quality_arguments = dict(arguments) + quality_arguments["format"] = "json" + quality_cmd = [ + str(binary), + "cli", + "--json", + tool_name, + json.dumps(quality_arguments, separators=(",", ":")), + ] + quality_elapsed: list[float] = [] + quality_hashes: list[str] = [] + data: dict[str, Any] = {} + for _ in range(REPEATED_JSON_TRIALS): + quality_proc, quality_elapsed_ms = command_result(quality_cmd, env, timeout) + if quality_proc.returncode != 0: + raise command_failure( + f"{tool_name}_quality_call", + quality_cmd, + env, + quality_proc, + quality_elapsed_ms, + ) + data = unwrap_cli_json(quality_proc.stdout) + quality_elapsed.append(round(quality_elapsed_ms, 3)) + quality_hashes.append( + hashlib.sha256(canonical_response_bytes(data)).hexdigest() + ) + result = build_tool_call_result( + data, + proc.stderr, + len(proc.stdout.encode("utf-8")), + elapsed_ms, + include_logs, + raw_payload, + ) + add_repeated_json_measurements(result, quality_elapsed, quality_hashes) + return result + + +def run_mcp_tool_call( + client: McpClient, + tool_name: str, + arguments: dict[str, Any], + include_logs: bool, +) -> dict[str, Any]: + raw_text, stderr, stdout_bytes, elapsed_ms = client.call_tool_text( + tool_name, arguments + ) + quality_arguments = dict(arguments) + quality_arguments["format"] = "json" + quality_elapsed: list[float] = [] + quality_hashes: list[str] = [] + data: dict[str, Any] = {} + for _ in range(REPEATED_JSON_TRIALS): + data, _, _, quality_elapsed_ms = client.call_tool(tool_name, quality_arguments) + quality_elapsed.append(round(quality_elapsed_ms, 3)) + quality_hashes.append( + hashlib.sha256(canonical_response_bytes(data)).hexdigest() + ) + result = build_tool_call_result( + data, stderr, stdout_bytes, elapsed_ms, include_logs, raw_text.encode("utf-8") + ) + add_repeated_json_measurements(result, quality_elapsed, quality_hashes) + return result + + +def add_repeated_json_measurements( + result: dict[str, Any], elapsed_ms: list[float], response_hashes: list[str] +) -> None: + ordered = sorted(elapsed_ms) + result["quality_probe_elapsed_ms"] = elapsed_ms[0] if elapsed_ms else None + result["repeated_json_trials_ms"] = elapsed_ms + result["repeated_json_latency_ms"] = { + "count": len(ordered), + "min": ordered[0] if ordered else None, + "median": ordered[len(ordered) // 2] if ordered else None, + "max": ordered[-1] if ordered else None, + } + result["repeated_json_response_sha256"] = response_hashes + result["repeated_json_payloads_byte_equal"] = len(set(response_hashes)) <= 1 + + +def run_tool_call_for_transport( + transport: str, + binary: Path, + env: dict[str, str], + tool_name: str, + arguments: dict[str, Any], + timeout: int, + include_logs: bool, + client: McpClient | None = None, +) -> dict[str, Any]: + if transport == "mcp": + if client is None: + raise RuntimeError("MCP transport requires an active client") + return run_mcp_tool_call(client, tool_name, arguments, include_logs) + return run_cli_tool_call(binary, env, tool_name, arguments, timeout, include_logs) + + +def summarize_elapsed_ms(probes: list[dict[str, Any]]) -> dict[str, Any]: + elapsed = sorted(float(probe["elapsed_ms"]) for probe in probes) + if not elapsed: + return {"count": 0} + return { + "count": len(elapsed), + "min_ms": elapsed[0], + "median_ms": elapsed[len(elapsed) // 2], + "max_ms": elapsed[-1], + } + + +def measure_cli_overhead_probes( + binary: Path, + env: dict[str, str], + tool_name: str, + count: int, + timeout: int, + include_logs: bool, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + if count <= 0: + return None + probes = [ + run_cli_tool_probe(binary, env, tool_name, timeout, include_logs, arguments) + for _ in range(count) + ] + return { + "tool": tool_name, + "trials": probes, + "summary": summarize_elapsed_ms(probes), + } + + +def measure_mcp_overhead_probes( + client: McpClient, + tool_name: str, + count: int, + include_logs: bool, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + if count <= 0: + return None + probes = [ + run_mcp_tool_probe(client, tool_name, include_logs, arguments) + for _ in range(count) + ] + return { + "tool": tool_name, + "trials": probes, + "summary": summarize_elapsed_ms(probes), + } + + +def measure_indexed_query_probes_for_transport( + transport: str, + binary: Path, + env: dict[str, str], + tool_name: str, + count: int, + project: str, + timeout: int, + include_logs: bool, + client: McpClient | None = None, +) -> dict[str, Any] | None: + """Measure a named file-backed project after its initial index exists.""" + arguments = {"project": project} + if transport == "mcp": + if client is None: + raise RuntimeError("MCP indexed-query probes require an active client") + daemon_log, daemon_log_offset = daemon_log_window(env) + result = measure_mcp_overhead_probes( + client, tool_name, count, include_logs, arguments + ) + census = summarize_daemon_mem_census_since(daemon_log, daemon_log_offset) + if result is not None and census is not None: + result["daemon_mem_census"] = census + profiles = summarize_daemon_profiles_since( + daemon_log, + daemon_log_offset, + ( + ("mcp_tool_execute", tool_name), + ("mcp_request_total", "tools/call"), + *INDEXED_QUERY_PROFILE_COMPONENTS, + ), + ) + if result is not None and profiles is not None: + result["daemon_profile"] = profiles + return result + return measure_cli_overhead_probes( + binary, env, tool_name, count, timeout, include_logs, arguments + ) + + +def remove_project_dbs(cache_dir: Path) -> list[str]: + removed: list[str] = [] + for path in cache_dir.iterdir(): + if not path.is_file(): + continue + if path.name == CONFIG_DB_NAME or not path.name.endswith(PROJECT_DB_SUFFIX): + continue + path.unlink() + removed.append(path.name) + for suffix in ("-wal", "-shm"): + sidecar = cache_dir / f"{path.name}{suffix}" + if sidecar.exists(): + sidecar.unlink() + removed.append(sidecar.name) + return removed + + +def find_project_db(cache_dir: Path) -> Path: + dbs = sorted( + path + for path in cache_dir.iterdir() + if path.is_file() + and path.name != CONFIG_DB_NAME + and path.name.endswith(PROJECT_DB_SUFFIX) + ) + primary_dbs = [ + path for path in dbs if DEPENDENCY_PROJECT_SEPARATOR not in path.stem + ] + if len(primary_dbs) != 1: + primary_names = ", ".join(path.name for path in primary_dbs) or "(none)" + all_names = ", ".join(path.name for path in dbs) or "(none)" + raise RuntimeError( + f"expected one primary project DB in {cache_dir}, " + f"found {len(primary_dbs)}: {primary_names}; " + f"all project DBs: {all_names}" + ) + return primary_dbs[0] + + +def remove_sqlite_sidecars(path: Path) -> None: + for suffix in ("-wal", "-shm"): + sidecar = Path(f"{path}{suffix}") + if sidecar.exists(): + sidecar.unlink() + + +def copy_sqlite_snapshot(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + destination.unlink() + remove_sqlite_sidecars(destination) + uri = f"{source.resolve().as_uri()}?mode=ro" + with ( + closing(sqlite3.connect(uri, uri=True)) as src, + closing(sqlite3.connect(str(destination))) as dst, + ): + src.backup(dst) + + +def clone_list_project_db( + source: Path, destination: Path, project: str, root_path: str +) -> None: + """Clone one valid project DB and rekey rows used by list_projects.""" + copy_sqlite_snapshot(source, destination) + with closing(sqlite3.connect(str(destination))) as con, con: + project_rows = con.execute("SELECT name FROM projects").fetchall() + if len(project_rows) != 1: + raise RuntimeError( + f"list-project fixture seed must contain one project, found {len(project_rows)}" + ) + old_project = str(project_rows[0][0]) + con.execute( + "UPDATE projects SET name = ?, root_path = ? WHERE name = ?", + (project, root_path, old_project), + ) + con.execute( + "UPDATE nodes SET project = ? WHERE project = ?", (project, old_project) + ) + con.execute( + "UPDATE edges SET project = ? WHERE project = ?", (project, old_project) + ) + + +def decode_sqlite_text(data: bytes) -> str: + return data.decode("utf-8", "surrogateescape") + + +def sqlite_cbm_source_span_label(label: str | None) -> int: + return int(label in SOURCE_SPAN_LABELS) + + +def query_rows(db_path: Path, sql: str, params: tuple[Any, ...]) -> list[str]: + con = sqlite3.connect(str(db_path)) + con.text_factory = decode_sqlite_text + con.create_function("cbm_source_span_label", 1, sqlite_cbm_source_span_label) + try: + rows = [str(row[0]) for row in con.execute(sql, params)] + finally: + con.close() + return rows + + +def canonical_query_rows(db_path: Path, project: str, sql: str) -> list[str]: + return query_rows(db_path, sql, (project,)) + + +def stream_query_fingerprint( + db_path: Path, sql: str, params: tuple[Any, ...] +) -> dict[str, Any]: + """Hash an ordered single-column query with O(1) Python memory.""" + digest = hashlib.sha256() + row_count = 0 + con = sqlite3.connect(str(db_path)) + con.text_factory = decode_sqlite_text + con.create_function("cbm_source_span_label", 1, sqlite_cbm_source_span_label) + try: + for row in con.execute(sql, params): + value = row[0] + payload = ( + value + if isinstance(value, bytes) + else str(value).encode("utf-8", "surrogateescape") + ) + digest.update(len(payload).to_bytes(8, "big")) + digest.update(payload) + row_count += 1 + finally: + con.close() + return {"row_count": row_count, "sha256": digest.hexdigest()} + + +def first_sorted_query_difference( + left_db: Path, + right_db: Path, + left_sql: str, + left_params: tuple[Any, ...], + right_sql: str, + right_params: tuple[Any, ...], +) -> tuple[str | None, str | None]: + """Return the first merge difference from two ordered queries in O(1) memory.""" + left_con = sqlite3.connect(str(left_db)) + right_con = sqlite3.connect(str(right_db)) + for con in (left_con, right_con): + con.text_factory = decode_sqlite_text + con.create_function("cbm_source_span_label", 1, sqlite_cbm_source_span_label) + try: + left_rows = iter(left_con.execute(left_sql, left_params)) + right_rows = iter(right_con.execute(right_sql, right_params)) + left = next(left_rows, None) + right = next(right_rows, None) + while left is not None and right is not None: + left_value = str(left[0]) + right_value = str(right[0]) + if left_value == right_value: + left = next(left_rows, None) + right = next(right_rows, None) + elif left_value < right_value: + return left_value, None + else: + return None, right_value + return ( + str(left[0]) if left is not None else None, + str(right[0]) if right is not None else None, + ) + finally: + left_con.close() + right_con.close() + + +def compare_query_rows( + left_db: Path, + right_db: Path, + kind: str, + left_sql: str, + left_params: tuple[Any, ...], + right_sql: str, + right_params: tuple[Any, ...], +) -> dict[str, Any]: + left = stream_query_fingerprint(left_db, left_sql, left_params) + right = stream_query_fingerprint(right_db, right_sql, right_params) + if left != right: + left_only, right_only = first_sorted_query_difference( + left_db, right_db, left_sql, left_params, right_sql, right_params + ) + return { + "equal": False, + "kind": kind, + "left_count": left["row_count"], + "right_count": right["row_count"], + "left_sha256": left["sha256"], + "right_sha256": right["sha256"], + "left_only": left_only, + "right_only": right_only, + } + return {"equal": True, "row_count": left["row_count"], "sha256": left["sha256"]} + + +CANONICAL_NODES_SQL = ( + "SELECT quote(label) || char(9) || quote(name) || char(9) || " + "quote(qualified_name) || char(9) || quote(coalesce(file_path,'')) || char(9) || " + "start_line || char(9) || end_line || char(9) || " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(n.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '') " + "FROM nodes n WHERE project = ?1 " + "ORDER BY label, name, qualified_name, coalesce(file_path,''), start_line, end_line, " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(n.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '')" +) + + +def build_canonical_edges_sql(edge_predicate: str = "") -> str: + return ( + "SELECT quote(s.label) || char(9) || quote(s.qualified_name) || char(9) || " + "quote(coalesce(s.file_path,'')) || char(9) || s.start_line || char(9) || " + "s.end_line || char(9) || quote(t.label) || char(9) || quote(t.qualified_name) || " + "char(9) || quote(coalesce(t.file_path,'')) || char(9) || t.start_line || char(9) || " + "t.end_line || char(9) || quote(e.type) || char(9) || " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(e.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '') " + "FROM edges e " + "JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id " + f"WHERE e.project = ?1 {edge_predicate}" + "ORDER BY s.label, s.qualified_name, coalesce(s.file_path,''), s.start_line, s.end_line, " + "t.label, t.qualified_name, coalesce(t.file_path,''), t.start_line, t.end_line, " + "e.type, COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(e.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '')" + ) + + +CANONICAL_EDGES_SQL = build_canonical_edges_sql() +CANONICAL_EDGES_WITHOUT_SEMANTIC_SQL = build_canonical_edges_sql( + "AND e.type <> 'SEMANTICALLY_RELATED' " +) + +CANONICAL_HASHES_SQL = ( + "SELECT quote(rel_path) || char(9) || quote(sha256) || char(9) || mtime_ns || char(9) || " + "size FROM file_hashes WHERE project = ?1 ORDER BY rel_path" +) + +CONTENT_HASHES_SQL = ( + "SELECT quote(rel_path) || char(9) || quote(sha256) || char(9) || size " + "FROM file_hashes WHERE project = ?1 ORDER BY rel_path" +) + +STABLE_NODES_SQL = ( + "SELECT quote(label) || char(9) || " + "quote(CASE WHEN label = 'Project' AND name = ?1 THEN '' ELSE name END) || char(9) || " + "quote(CASE WHEN qualified_name = ?1 THEN '' " + "WHEN substr(qualified_name, 1, length(?1) + 1) = ?1 || '.' " + "THEN substr(qualified_name, length(?1) + 2) ELSE qualified_name END) || char(9) || " + "quote(coalesce(file_path,'')) || char(9) || start_line || char(9) || end_line " + "FROM nodes WHERE project = ?1 ORDER BY 1" +) + +STABLE_EDGES_SQL = ( + "SELECT quote(CASE WHEN s.qualified_name = ?1 THEN '' " + "WHEN substr(s.qualified_name, 1, length(?1) + 1) = ?1 || '.' " + "THEN substr(s.qualified_name, length(?1) + 2) ELSE s.qualified_name END) || char(9) || " + "quote(CASE WHEN t.qualified_name = ?1 THEN '' " + "WHEN substr(t.qualified_name, 1, length(?1) + 1) = ?1 || '.' " + "THEN substr(t.qualified_name, length(?1) + 2) ELSE t.qualified_name END) || char(9) || " + "quote(e.type) FROM edges e " + "JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.project = ?1 ORDER BY 1" +) + +STABLE_SEMANTIC_SCORES_SQL = ( + "SELECT quote(CASE WHEN s.qualified_name = ?1 THEN '' " + "WHEN substr(s.qualified_name, 1, length(?1) + 1) = ?1 || '.' " + "THEN substr(s.qualified_name, length(?1) + 2) ELSE s.qualified_name END) || char(9) || " + "quote(CASE WHEN t.qualified_name = ?1 THEN '' " + "WHEN substr(t.qualified_name, 1, length(?1) + 1) = ?1 || '.' " + "THEN substr(t.qualified_name, length(?1) + 2) ELSE t.qualified_name END) || char(9) || " + "quote(e.type) || char(9) || " + "coalesce(quote(CAST(json_extract(e.properties, '$.score') AS TEXT)), 'NULL') || char(9) || " + "coalesce(quote(CAST(json_extract(e.properties, '$.jaccard') AS TEXT)), 'NULL') " + "FROM edges e JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id " + "WHERE e.project = ?1 AND e.type IN ('SIMILAR_TO','SEMANTICALLY_RELATED') ORDER BY 1" +) + +ACTIVE_OVERLAY_CTE_SQL = ( + "WITH active_overlay_files AS (" + " SELECT project, rel_path, MAX(overlay_generation) AS overlay_generation" + " FROM (" + " SELECT n.project, n.rel_path, n.overlay_generation" + " FROM overlay_nodes n" + " JOIN overlay_generations g" + " ON g.project = n.project AND g.overlay_generation = n.overlay_generation" + " WHERE g.status = ?1 AND n.project = ?4" + " UNION" + " SELECT e.project, e.rel_path, e.overlay_generation" + " FROM overlay_edges e" + " JOIN overlay_generations g" + " ON g.project = e.project AND g.overlay_generation = e.overlay_generation" + " WHERE g.status = ?1 AND e.project = ?4" + " UNION" + " SELECT t.project, t.rel_path, t.overlay_generation" + " FROM overlay_tombstones t" + " JOIN overlay_generations g" + " ON g.project = t.project AND g.overlay_generation = t.overlay_generation" + " WHERE g.status = ?1 AND t.active = ?3 AND t.project = ?4" + " ) overlay_files" + " GROUP BY project, rel_path" + "), active_file_tombstones AS (" + " SELECT t.project, t.rel_path, MAX(t.overlay_generation) AS overlay_generation" + " FROM overlay_tombstones t" + " JOIN overlay_generations g" + " ON g.project = t.project AND g.overlay_generation = t.overlay_generation" + " WHERE g.status = ?1 AND t.entity_kind = ?2 AND t.active = ?3 AND t.project = ?4" + " GROUP BY t.project, t.rel_path" + "), active_node_candidates AS (" + " SELECT 0 AS overlay_row, n.project, n.label, n.name, n.qualified_name, n.file_path," + " n.start_line, n.end_line, n.properties" + " FROM nodes n" + " WHERE n.project = ?4" + " AND NOT EXISTS (SELECT 1 FROM active_file_tombstones af" + " WHERE af.project = n.project AND af.rel_path = n.file_path)" + " UNION ALL" + " SELECT 1 AS overlay_row, n.project, n.label, n.name, n.qualified_name, n.file_path," + " n.start_line, n.end_line, n.properties" + " FROM overlay_nodes n" + " JOIN active_overlay_files af" + " ON af.project = n.project AND af.rel_path = n.rel_path" + " AND af.overlay_generation = n.overlay_generation" + " WHERE n.owned = ?5" + "), active_nodes AS (" + " SELECT project, label, name, qualified_name, file_path, start_line, end_line, properties" + " FROM (" + " SELECT c.*, ROW_NUMBER() OVER (" + " PARTITION BY c.project, c.qualified_name" + " ORDER BY cbm_source_span_label(c.label) DESC," + " CASE WHEN cbm_source_span_label(c.label) = 1" + " THEN CASE WHEN c.file_path <> '' THEN 1 ELSE 0 END" + " ELSE c.overlay_row END DESC," + " CASE WHEN cbm_source_span_label(c.label) = 1" + " AND c.start_line > 0 AND c.end_line >= c.start_line" + " THEN c.end_line - c.start_line + 1 ELSE 0 END DESC," + " CASE WHEN cbm_source_span_label(c.label) = 1 THEN c.start_line ELSE 0 END ASC," + " CASE WHEN cbm_source_span_label(c.label) = 1 THEN c.end_line ELSE 0 END DESC," + " CASE WHEN cbm_source_span_label(c.label) = 1 THEN c.file_path ELSE '' END ASC," + " c.overlay_row DESC" + " ) AS rn" + " FROM active_node_candidates c" + " ) ranked_nodes" + " WHERE rn = 1" + "), active_edges AS (" + " SELECT e.project, s.qualified_name AS source_qn, t.qualified_name AS target_qn," + " e.type, e.properties" + " FROM edges e" + " JOIN nodes s ON s.id = e.source_id" + " JOIN nodes t ON t.id = e.target_id" + " WHERE e.project = ?4" + " AND NOT EXISTS (SELECT 1 FROM active_file_tombstones af" + " WHERE af.project = s.project AND af.rel_path = s.file_path)" + " AND NOT EXISTS (SELECT 1 FROM active_file_tombstones af" + " WHERE af.project = t.project AND af.rel_path = t.file_path)" + " UNION" + " SELECT e.project, e.source_qn, e.target_qn, e.type, e.properties" + " FROM overlay_edges e" + " JOIN active_overlay_files af" + " ON af.project = e.project AND af.rel_path = e.rel_path" + " AND af.overlay_generation = e.overlay_generation" + " WHERE e.owned = ?5" + ") " +) + +ACTIVE_OVERLAY_NODES_SQL = ( + ACTIVE_OVERLAY_CTE_SQL + + "SELECT quote(label) || char(9) || quote(name) || char(9) || " + "quote(qualified_name) || char(9) || quote(coalesce(file_path,'')) || char(9) || " + "start_line || char(9) || end_line || char(9) || " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(n.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '') " + "FROM active_nodes n WHERE project = ?4 " + "ORDER BY label, name, qualified_name, coalesce(file_path,''), start_line, end_line, " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(n.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '')" +) + +ACTIVE_OVERLAY_EDGES_SQL = ( + ACTIVE_OVERLAY_CTE_SQL + + "SELECT quote(s.label) || char(9) || quote(s.qualified_name) || char(9) || " + "quote(coalesce(s.file_path,'')) || char(9) || s.start_line || char(9) || " + "s.end_line || char(9) || quote(t.label) || char(9) || quote(t.qualified_name) || " + "char(9) || quote(coalesce(t.file_path,'')) || char(9) || t.start_line || char(9) || " + "t.end_line || char(9) || quote(e.type) || char(9) || " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(e.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '') " + "FROM active_edges e " + "JOIN active_nodes s ON s.project = e.project AND s.qualified_name = e.source_qn " + "JOIN active_nodes t ON t.project = e.project AND t.qualified_name = e.target_qn " + "WHERE e.project = ?4 " + "ORDER BY s.label, s.qualified_name, coalesce(s.file_path,''), s.start_line, s.end_line, " + "t.label, t.qualified_name, coalesce(t.file_path,''), t.start_line, t.end_line, " + "e.type, COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(e.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '')" +) + + +def stable_graph_fingerprint(db_path: Path, project: str) -> dict[str, Any]: + """Return path-normalized experiment identity with O(1) Python memory.""" + components: dict[str, dict[str, Any]] = {} + aggregate = hashlib.sha256() + for name, sql in ( + ("nodes", STABLE_NODES_SQL), + ("edges", STABLE_EDGES_SQL), + ("semantic_scores", STABLE_SEMANTIC_SCORES_SQL), + ("source_files", CONTENT_HASHES_SQL), + ): + fingerprint = stream_query_fingerprint(db_path, sql, (project,)) + components[name] = fingerprint + name_payload = name.encode("ascii") + aggregate.update(len(name_payload).to_bytes(8, "big")) + aggregate.update(name_payload) + aggregate.update(fingerprint["row_count"].to_bytes(8, "big")) + aggregate.update(bytes.fromhex(fingerprint["sha256"])) + return {"sha256": aggregate.hexdigest(), "components": components} + + +def compare_canonical_graph( + left_db: Path, right_db: Path, project: str +) -> dict[str, Any]: + for kind, sql in ( + ("canonical nodes", CANONICAL_NODES_SQL), + ("canonical edges", CANONICAL_EDGES_SQL), + ("file hashes", CANONICAL_HASHES_SQL), + ): + result = compare_query_rows( + left_db, right_db, kind, sql, (project,), sql, (project,) + ) + if not result["equal"]: + return result + return {"equal": True} + + +def compare_graph_excluding_declared_stale_views( + left_db: Path, + right_db: Path, + project: str, + stale_views: list[str], +) -> dict[str, Any] | None: + """Verify strict graph equality after excluding only explicitly stale derived rows.""" + if "semantic_edges" not in stale_views: + return None + excluded_edge_types = ["SEMANTICALLY_RELATED"] + for kind, sql in ( + ("canonical nodes excluding declared stale views", CANONICAL_NODES_SQL), + ( + "canonical edges excluding declared stale views", + CANONICAL_EDGES_WITHOUT_SEMANTIC_SQL, + ), + ("file hashes excluding declared stale views", CANONICAL_HASHES_SQL), + ): + result = compare_query_rows( + left_db, right_db, kind, sql, (project,), sql, (project,) + ) + if not result["equal"]: + return { + **result, + "declared_stale_views": stale_views, + "excluded_edge_types": excluded_edge_types, + } + return { + "equal": True, + "declared_stale_views": stale_views, + "excluded_edge_types": excluded_edge_types, + } + + +def compare_active_overlay_graph( + left_db: Path, right_db: Path, project: str +) -> dict[str, Any]: + left_params = ( + OVERLAY_STATUS_READY, + OVERLAY_TOMBSTONE_FILE, + OVERLAY_TOMBSTONE_ACTIVE, + project, + OVERLAY_ROW_OWNED, + ) + for kind, left_sql, right_sql in ( + ("active overlay nodes", ACTIVE_OVERLAY_NODES_SQL, CANONICAL_NODES_SQL), + ("active overlay edges", ACTIVE_OVERLAY_EDGES_SQL, CANONICAL_EDGES_SQL), + ): + result = compare_query_rows( + left_db, right_db, kind, left_sql, left_params, right_sql, (project,) + ) + if not result["equal"]: + return result + return {"equal": True} + + +def graph_gate_for_publish_kind( + canonical: dict[str, Any], + publish_kind: str | None, + oracle_passed: bool | None = None, + active_overlay: dict[str, Any] | None = None, + freshness_scoped: dict[str, Any] | None = None, +) -> dict[str, Any]: + canonical_equal = bool(canonical.get("equal")) + active_overlay_equal = bool(active_overlay and active_overlay.get("equal")) + if publish_kind == PUBLISH_INCREMENTAL_OVERLAY and active_overlay is not None: + return { + "passed": active_overlay_equal, + "policy": "overlay_active_graph", + "canonical_equal": canonical_equal, + "active_overlay_equal": active_overlay_equal, + "reason": ( + "overlay publish leaves canonical rows unchanged; validate active overlay " + "nodes and edges against a fresh full graph" + ), + } + if publish_kind == PUBLISH_INCREMENTAL_OVERLAY and oracle_passed is not None: + return { + "passed": bool(oracle_passed), + "policy": "overlay_active_oracles", + "canonical_equal": canonical_equal, + "reason": ( + "overlay publish leaves canonical rows unchanged; self-dogfood gates " + "on active read oracles and freshness metadata" + ), + } + # A stale ledger entry can describe a disabled or currently unused derived + # view. Exact canonical equality is stronger evidence and must not be + # downgraded to a scoped-freshness pass or excluded from Pareto analysis. + if canonical_equal: + return { + "passed": True, + "policy": "canonical_graph", + "canonical_equal": True, + } + if freshness_scoped is not None and freshness_scoped.get("equal") is True: + return { + "passed": True, + "policy": "declared_stale_derived_views", + "canonical_equal": canonical_equal, + "freshness_scoped_equal": True, + "declared_stale_views": freshness_scoped.get("declared_stale_views", []), + "excluded_edge_types": freshness_scoped.get("excluded_edge_types", []), + "reason": ( + "the full graph intentionally retains declared-stale derived rows; " + "all non-stale canonical rows equal the fresh graph" + ), + } + return { + "passed": canonical_equal, + "policy": "canonical_graph", + "canonical_equal": canonical_equal, + } + + +def frontier_coverage_gate( + scenario_metadata: dict[str, Any], + incremental: dict[str, Any], + exact_cap: int | None = None, +) -> dict[str, Any]: + expected_publish_kind = scenario_metadata.get("expected_publish_kind") + expected_reason = scenario_metadata.get("expected_reason") + if isinstance(expected_publish_kind, str) and isinstance(expected_reason, str): + observed_publish_kind = incremental.get("publish_kind") + observed_reason = incremental.get("exact_reason") + passed = ( + observed_publish_kind == expected_publish_kind + and observed_reason == expected_reason + ) + result = { + "passed": passed, + "applicable": True, + "contract": "safe_full_rebuild", + "expected_publish_kind": expected_publish_kind, + "observed_publish_kind": observed_publish_kind, + "expected_reason": expected_reason, + "observed_reason": observed_reason, + } + if not passed: + result["reason"] = ( + "observed fallback route does not match the fixture contract" + ) + return result + expected = scenario_metadata.get("expected_minimum_affected_files") + if not isinstance(expected, int): + return {"passed": True, "applicable": False} + exact_delta = incremental.get("response", {}).get("exact_delta", {}) + observed = exact_delta.get("affected_paths") + if not isinstance(observed, int): + observed = incremental.get("exact_route_detail", {}).get( + "frontier_expanded_files" + ) + if isinstance(exact_cap, int) and exact_cap < expected: + observed_publish_kind = incremental.get("publish_kind") + observed_reason = incremental.get("exact_reason") + truncated = exact_delta.get("affected_paths_truncated") is True + passed = ( + observed_publish_kind in {PUBLISH_FULL, PUBLISH_INCREMENTAL_CONTAINMENT} + and observed_reason == "frontier_too_large" + and truncated + ) + result = { + "passed": passed, + "applicable": True, + "contract": "configured_cap_fallback", + "configured_exact_cap": exact_cap, + "expected_minimum_affected_files": expected, + "observed_affected_files": observed, + "observed_publish_kind": observed_publish_kind, + "observed_reason": observed_reason, + "affected_paths_truncated": truncated, + } + if not passed: + result["reason"] = ( + "configured cap fallback requires containment/full publication, " + "frontier_too_large, and truncation evidence" + ) + return result + passed = isinstance(observed, int) and observed >= expected + result = { + "passed": passed, + "applicable": True, + "contract": "exact_frontier", + "expected_minimum_affected_files": expected, + "observed_affected_files": observed, + } + if not passed: + result["reason"] = "observed frontier is smaller than the fixture contract" + return result + + +def validate_isolated_cache_dir(cache_dir: Path) -> Path: + """Fail before a candidate can mutate a live or previously populated store. + + Cross-version candidates may interpret newer SQLite metadata as corruption and + perform their own recovery. Every benchmark phase must therefore start from a + harness-owned empty directory, never the caller's active cache or a prior cell. + """ + resolved = cache_dir.expanduser().resolve() + active_cache = os.environ.get("CBM_CACHE_DIR") + live_caches = {Path.home() / ".cache" / "codebase-memory-mcp"} + if active_cache: + live_caches.add(Path(active_cache).expanduser()) + if any(resolved == path.resolve() for path in live_caches): + raise RuntimeError( + f"benchmark cache resolves to a live cache directory: {resolved}" + ) + if resolved.is_dir(): + existing = sorted( + path.name + for path in resolved.iterdir() + if path.is_file() and path.name.endswith(PROJECT_DB_SUFFIX) + ) + if existing: + raise RuntimeError( + "benchmark cache contains an existing project database: " + + ", ".join(existing) + ) + return resolved + + +def build_env( + cache_dir: Path, product_environment: dict[str, str] | None = None +) -> dict[str, str]: + isolated_cache = validate_isolated_cache_dir(cache_dir) + env = { + key: value for key, value in os.environ.items() if not key.startswith("CBM_") + } + explicit_product_environment = validate_product_environment( + product_environment or {} + ) + env.update(explicit_product_environment) + env["CBM_CACHE_DIR"] = str(isolated_cache) + env["CBM_AUTO_INDEX"] = "false" + env["CBM_CONTEXT_INJECTION"] = "false" + # The supervisor retains successful worker logs only in profile mode. The + # harness streams their exact memory/timing markers before cleaning the cache. + env["CBM_PROFILE"] = "1" + return env + + +def benchmark_environment_policy( + product_environment: dict[str, str] | None = None, +) -> dict[str, Any]: + explicit_product_environment = dict(sorted((product_environment or {}).items())) + workers = explicit_product_environment.get("CBM_WORKERS") + policy = { + "inherited_product_environment": "remove_all_CBM_prefix_variables", + "harness_overrides": { + "CBM_AUTO_INDEX": "false", + "CBM_CONTEXT_INJECTION": "false", + "CBM_PROFILE": "1", + }, + "worker_selection": ( + f"explicit_CBM_WORKERS={workers}" + if workers is not None + else "candidate_default_with_CBM_WORKERS_unset" + ), + "cache_scope": "isolated_per_benchmark_case", + } + if explicit_product_environment: + policy["explicit_product_environment"] = explicit_product_environment + return policy + + +def prepare_matrix_scenario( + name: str, + repo_dir: Path, + files: int, + funcs_per_file: int, + args: argparse.Namespace, + case_root: Path, +) -> dict[str, Any]: + frontier_language = MATRIX_FRONTIER_SCENARIOS.get(name) + if frontier_language: + return create_inbound_frontier_repo( + repo_dir, frontier_language, args.frontier_files + ) + if name in { + "go_modify_1", + "go_modify_2", + "go_create", + "go_delete", + "go_rename", + "go_new_folder", + }: + create_repo(repo_dir, files, funcs_per_file) + return {"source": "synthetic_go"} + if name == "route_decorator": + create_route_repo(repo_dir, "/api/orders") + return {"source": "synthetic_route"} + if name == "python_reexport": + create_python_reexport_repo(repo_dir) + return {"source": "synthetic_python_reexport"} + if name == "fastapi_insert_probe": + return copy_fastapi_head_to_case(args, repo_dir, case_root) + raise ValueError(f"unknown matrix scenario: {name}") + + +def mutate_matrix_scenario(name: str, repo_dir: Path, funcs_per_file: int) -> list[str]: + frontier_language = MATRIX_FRONTIER_SCENARIOS.get(name) + if frontier_language: + return mutate_inbound_frontier_repo(repo_dir, frontier_language) + if name == "go_modify_1": + return modify_existing_files(repo_dir, 1, funcs_per_file) + if name == "go_modify_2": + return modify_existing_files(repo_dir, 2, funcs_per_file) + if name == "go_create": + rel = Path("pkg") / "file_created.go" + write_text(repo_dir / rel, go_file_content(9999, 1, funcs_per_file)) + return [rel.as_posix()] + if name == "go_delete": + rel = Path("pkg") / "file_0000.go" + (repo_dir / rel).unlink() + return [rel.as_posix()] + if name == "go_rename": + old_rel = Path("pkg") / "file_0000.go" + new_rel = Path("pkg") / "file_renamed.go" + (repo_dir / old_rel).unlink() + write_text(repo_dir / new_rel, go_file_content(9998, 1, funcs_per_file)) + return [old_rel.as_posix(), new_rel.as_posix()] + if name == "go_new_folder": + rel = Path("newpkg") / "leaf.go" + write_text( + repo_dir / rel, + "package newpkg\n\nfunc NewFolderLeaf() int {\n\treturn 23\n}\n", + ) + return [rel.as_posix()] + if name == "route_decorator": + create_route_repo(repo_dir, "/api/items") + return ["routes.py"] + if name == "python_reexport": + rel = Path("fastapi") / "__init__.py" + write_text(repo_dir / rel, "from .openapi.models import Header\n") + return [rel.as_posix()] + if name == "fastapi_insert_probe": + rel = Path(FASTAPI_PROBE_REL_PATH) + path = repo_dir / rel + source = path.read_text(encoding="utf-8") + insert = ( + "\n" + " def cbm_frontier_noop_mask_probe(self) -> int:\n" + f" return {FASTAPI_PROBE_RETURN_VALUE}\n" + ) + if FASTAPI_PROBE_INSERT_BEFORE not in source: + raise RuntimeError( + f"FastAPI probe insertion point not found: {rel.as_posix()}" + ) + mutated = source.replace( + FASTAPI_PROBE_INSERT_BEFORE, insert + FASTAPI_PROBE_INSERT_BEFORE, 1 + ) + try: + compile(mutated, rel.as_posix(), "exec") + except SyntaxError as exc: + raise RuntimeError( + f"FastAPI probe mutation produced invalid Python: {exc}" + ) from exc + path.write_text(mutated, encoding="utf-8") + return [rel.as_posix()] + raise ValueError(f"unknown matrix scenario: {name}") + + +def resolve_git_repo_root(repo_root: Path, timeout: int) -> Path: + root = repo_root.expanduser().resolve() + return Path( + command_stdout(["git", "rev-parse", "--show-toplevel"], timeout, root) + ).resolve() + + +def git_metadata(repo_root: Path, timeout: int) -> dict[str, Any]: + def maybe(args: list[str]) -> str: + try: + return command_stdout(["git", *args], timeout, repo_root) + except Exception as exc: + # Metadata collection must not abort the benchmark measurement. + return f"" + + return { + "repo_root": str(repo_root), + "head": maybe(["rev-parse", "HEAD"]), + "short_head": maybe(["rev-parse", "--short", "HEAD"]), + "branch": maybe(["branch", "--show-current"]), + "dirty_status_short": maybe(["status", "--short"]), + } + + +def binary_metadata(binary: Path) -> dict[str, Any]: + digest = hashlib.sha256() + with binary.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + stat = binary.stat() + return { + "path": str(binary.resolve()), + "size_bytes": stat.st_size, + "sha256": digest.hexdigest(), + } + + +def clone_real_repo(url: str, target: Path, timeout: int) -> Path: + target.parent.mkdir(parents=True, exist_ok=True) + proc, _ = command_result( + ["git", "clone", "--depth=1", url, str(target)], + dict(os.environ), + timeout, + ) + if proc.returncode != 0: + raise RuntimeError(f"git clone failed for {url}: {proc.stderr.strip()}") + return target + + +def resolve_fastapi_source(args: argparse.Namespace, case_root: Path) -> Path: + candidates: list[Path] = [] + if args.fastapi_repo: + candidates.append(Path(args.fastapi_repo).expanduser()) + env_repo = os.environ.get("CBM_FASTAPI_REPO") + if env_repo: + candidates.append(Path(env_repo).expanduser()) + candidates.extend( + [ + Path.home() / "source" / "fastapi", + Path.home() / ".cache" / "codebase-memory-mcp" / "bench-repos" / "fastapi", + ] + ) + for candidate in candidates: + if (candidate / FASTAPI_PROBE_REL_PATH).is_file(): + return resolve_git_repo_root(candidate, args.timeout) + if not args.clone_missing_real_repos: + searched = ", ".join(str(path) for path in candidates) + raise RuntimeError( + "fastapi_insert_probe requires --fastapi-repo, CBM_FASTAPI_REPO, " + f"or --clone-missing-real-repos; searched: {searched}" + ) + return clone_real_repo(args.fastapi_url, case_root / "source-fastapi", args.timeout) + + +def copy_git_head_to_dir(source_repo: Path, dest: Path, timeout: int) -> None: + if dest.exists() and any(dest.iterdir()): + raise RuntimeError(f"destination is not empty: {dest}") + dest.mkdir(parents=True, exist_ok=True) + raw = command_stdout_bytes( + ["git", "ls-tree", "-r", "--name-only", "-z", "HEAD"], timeout, source_repo + ) + rel_paths = [ + item.decode("utf-8", "surrogateescape") for item in raw.split(b"\0") if item + ] + for rel_path in rel_paths: + blob = command_stdout_bytes( + ["git", "show", f"HEAD:{rel_path}"], timeout, source_repo + ) + target = dest / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(blob) + + +def copy_git_revision_to_dir( + source_repo: Path, + dest: Path, + revision: str, + timeout: int, + *, + excluded_prefixes: tuple[str, ...] = (), +) -> dict[str, Any]: + """Materialize tracked files from one exact commit without source dirty state.""" + source_root = resolve_git_repo_root(source_repo, timeout) + exact_revision = command_stdout( + ["git", "rev-parse", f"{revision}^{{commit}}"], timeout, source_root + ) + tree = command_stdout( + ["git", "rev-parse", f"{exact_revision}^{{tree}}"], timeout, source_root + ) + dirty_status = command_stdout(["git", "status", "--short"], timeout, source_root) + if dest.exists() and any(dest.iterdir()): + raise RuntimeError(f"destination is not empty: {dest}") + dest.mkdir(parents=True, exist_ok=True) + archive_path = dest.parent / f".cbm-background-{os.getpid()}-{time.time_ns()}.tar" + try: + proc, _ = command_result( + [ + "git", + "archive", + "--format=tar", + f"--output={archive_path}", + exact_revision, + ], + dict(os.environ), + timeout, + cwd=source_root, + ) + if proc.returncode != 0: + raise RuntimeError(f"git archive failed: {proc.stderr.strip()}") + destination_root = dest.resolve() + with tarfile.open(archive_path, mode="r:") as archive: + excluded_roots = tuple(prefix.rstrip("/") for prefix in excluded_prefixes) + members = [ + member + for member in archive.getmembers() + if not any( + member.name == root or member.name.startswith(f"{root}/") + for root in excluded_roots + ) + ] + for member in members: + target = (dest / member.name).resolve() + if ( + target != destination_root + and destination_root not in target.parents + ): + raise RuntimeError( + f"git archive member escapes destination: {member.name}" + ) + archive.extractall(dest, members=members, filter="data") + finally: + if archive_path.exists(): + archive_path.unlink() + return { + "source_repo": str(source_root), + "revision": exact_revision, + "tree": tree, + "source_dirty_status_short": dirty_status, + "excluded_prefixes": list(excluded_prefixes), + "copy_policy": "git_archive_tracked_files_from_exact_commit", + } + + +def copy_fastapi_head_to_case( + args: argparse.Namespace, repo_dir: Path, case_root: Path +) -> dict[str, Any]: + source_repo = resolve_fastapi_source(args, case_root) + copy_git_head_to_dir(source_repo, repo_dir, args.timeout) + return { + "source_repo": str(source_repo), + "source_git": git_metadata(source_repo, args.timeout), + "copy_policy": "git_tracked_files_from_HEAD", + } + + +def create_self_dogfood_worktree( + source_repo: Path, + case_root: Path, + timeout: int, + revision: str, +) -> Path: + repo_dir = case_root / SELF_DOGFOOD_REPO_SUBDIR + if repo_dir.exists(): + raise RuntimeError(f"self-dogfood worktree already exists: {repo_dir}") + proc, _ = command_result( + ["git", "worktree", "add", "--detach", str(repo_dir), revision], + dict(os.environ), + timeout, + source_repo, + ) + if proc.returncode != 0: + raise RuntimeError(f"git worktree add failed: {proc.stderr.strip()}") + return repo_dir + + +def remove_self_dogfood_worktree( + source_repo: Path, repo_dir: Path, timeout: int +) -> dict[str, Any]: + cleanup: dict[str, Any] = { + "requested": True, + "path": str(repo_dir), + "removed": False, + } + proc, _ = command_result( + ["git", "worktree", "remove", "--force", str(repo_dir)], + dict(os.environ), + timeout, + source_repo, + ) + if proc.returncode != 0: + cleanup["git_worktree_remove_error"] = proc.stderr.strip() + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup["removed"] = not repo_dir.exists() + return cleanup + + +def self_dogfood_marker(name: str) -> str: + return f"{SELF_DOGFOOD_MARKER_PREFIX}_{name}" + + +def append_c_marker_function( + repo_dir: Path, rel_path: str, marker: str, value: int +) -> str: + append_text( + repo_dir / rel_path, + (f"\nstatic int {marker}(void) {{\n return {value};\n}}\n"), + ) + return rel_path + + +def create_c_marker_file(repo_dir: Path, rel_path: str, marker: str, value: int) -> str: + path = repo_dir / rel_path + if path.exists(): + raise RuntimeError( + f"benchmark new-file mutation target already exists: {rel_path}" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"static int {marker}(void) {{\n return {value};\n}}\n", + encoding="utf-8", + ) + return rel_path + + +def mutate_self_dogfood_scenario(name: str, repo_dir: Path) -> dict[str, Any]: + marker = self_dogfood_marker(name) + changed: list[str] = [] + paths = SELF_DOGFOOD_SCENARIO_PATHS.get(name) + if paths is None: + raise ValueError(f"unknown self-dogfood scenario: {name}") + before_hashes = { + path: file_sha256(repo_dir / path) if (repo_dir / path).is_file() else None + for path in paths + } + + def finish(document: dict[str, Any]) -> dict[str, Any]: + document["source_hashes"] = [ + { + "path": path, + "before_sha256": before_hashes[path], + "after_sha256": ( + file_sha256(repo_dir / path) + if (repo_dir / path).is_file() + else None + ), + } + for path in paths + ] + return document + + if name == "noop": + return finish( + { + "marker": None, + "changed_paths": changed, + "description": "no source mutation", + } + ) + if name == "one_source_file": + changed.append( + append_c_marker_function( + repo_dir, "src/pipeline/pipeline_internal.h", marker, 4101 + ) + ) + return finish( + { + "marker": marker, + "changed_paths": changed, + "description": "single C header edit", + } + ) + if name == "route_handler": + append_text( + repo_dir / "src/ui/http_server.c", + ( + "\n" + f"static int {marker}(const char *path) {{\n" + ' return cbm_http_path_match(path, "/api/pan4-oracle");\n' + "}\n" + ), + ) + changed.append("src/ui/http_server.c") + return finish( + { + "marker": marker, + "changed_paths": changed, + "description": "HTTP UI handler source edit with route literal oracle", + } + ) + if name == "c_new_leaf": + changed.append( + create_c_marker_file( + repo_dir, + "src/cbm_benchmark_leaf.c", + marker, + 4102, + ) + ) + return finish( + { + "marker": marker, + "changed_paths": changed, + "description": "new isolated C source file", + } + ) + if name == "store_pipeline_batch": + changed.append( + append_c_marker_function(repo_dir, "src/store/store.h", marker, 4103) + ) + second_marker = f"{marker}_pipeline" + changed.append( + append_c_marker_function( + repo_dir, "src/pipeline/pipeline_internal.h", second_marker, 4104 + ) + ) + return finish( + { + "marker": marker, + "secondary_marker": second_marker, + "changed_paths": changed, + "description": "small store plus pipeline header batch", + } + ) + if name == "multi_file_small": + changed.append( + append_c_marker_function(repo_dir, "src/mcp/mcp.c", marker, 4105) + ) + second_marker = f"{marker}_test" + changed.append( + append_c_marker_function(repo_dir, "tests/test_mcp.c", second_marker, 4106) + ) + return finish( + { + "marker": marker, + "secondary_marker": second_marker, + "changed_paths": changed, + "description": "small production plus test source batch", + } + ) + + +def oracle_passed(tool_result: dict[str, Any], marker: str | None) -> bool: + if not marker: + return True + response = tool_result.get("response") + return marker in json.dumps(response, sort_keys=True) + + +def canonical_pair(source: str, target: str) -> tuple[str, str]: + """Return an order-independent pair identity without losing endpoint names.""" + if ( + not isinstance(source, str) + or not source + or not isinstance(target, str) + or not target + ): + raise ValueError("pair endpoints must be non-empty strings") + if source == target: + raise ValueError("pair endpoints must be distinct") + return (source, target) if source < target else (target, source) + + +def score_pair_classification( + observed_pairs: list[dict[str, Any]], + judgments: list[dict[str, Any]], +) -> dict[str, Any]: + """Score unordered observed pairs against explicit positive/negative judgments. + + Natural large-repository results outside the bounded judgment set are retained as + unjudged observations. They are intentionally excluded from the confusion matrix: + incomplete ground truth cannot turn an unknown pair into a false positive. + """ + judgment_by_pair: dict[tuple[str, str], dict[str, Any]] = {} + for judgment in judgments: + if not isinstance(judgment, dict): + raise ValueError("pair judgment must be an object") + pair = canonical_pair(judgment.get("source"), judgment.get("target")) + if pair in judgment_by_pair: + raise ValueError(f"duplicate pair judgment: {pair[0]} <-> {pair[1]}") + expected = judgment.get("expected") + if not isinstance(expected, bool): + raise ValueError("pair judgment expected must be boolean") + category = judgment.get("category", "uncategorized") + if not isinstance(category, str) or not category: + raise ValueError("pair judgment category must be a non-empty string") + judgment_by_pair[pair] = { + **judgment, + "source": pair[0], + "target": pair[1], + "expected": expected, + "category": category, + } + + observed_by_pair: dict[tuple[str, str], dict[str, Any]] = {} + for observed in observed_pairs: + if not isinstance(observed, dict): + raise ValueError("observed pair must be an object") + pair = canonical_pair(observed.get("source"), observed.get("target")) + observed_by_pair.setdefault( + pair, + {**observed, "source": pair[0], "target": pair[1]}, + ) + + confusion = {"tp": 0, "fp": 0, "fn": 0, "tn": 0} + witnesses: dict[str, list[dict[str, Any]]] = {key: [] for key in confusion} + categories: dict[str, dict[str, int]] = {} + for pair, judgment in judgment_by_pair.items(): + observed = observed_by_pair.get(pair) + if judgment["expected"]: + outcome = "tp" if observed is not None else "fn" + else: + outcome = "fp" if observed is not None else "tn" + confusion[outcome] += 1 + category = judgment["category"] + category_counts = categories.setdefault( + category, + {"tp": 0, "fp": 0, "fn": 0, "tn": 0}, + ) + category_counts[outcome] += 1 + witnesses[outcome].append( + { + "source": pair[0], + "target": pair[1], + "category": category, + "observed": observed, + } + ) + + unjudged_observed = [ + observed + for pair, observed in sorted(observed_by_pair.items()) + if pair not in judgment_by_pair + ] + precision_denominator = confusion["tp"] + confusion["fp"] + recall_denominator = confusion["tp"] + confusion["fn"] + negative_denominator = confusion["fp"] + confusion["tn"] + precision = ( + confusion["tp"] / precision_denominator if precision_denominator else None + ) + recall = confusion["tp"] / recall_denominator if recall_denominator else None + f1 = ( + 2.0 * precision * recall / (precision + recall) + if precision is not None and recall is not None and precision + recall > 0 + else None + ) + false_positive_rate = ( + confusion["fp"] / negative_denominator if negative_denominator else None + ) + return { + "judgment_count": len(judgment_by_pair), + "observed_pair_count": len(observed_by_pair), + "confusion": confusion, + "precision": precision, + "recall": recall, + "f1": f1, + "false_positive_rate": false_positive_rate, + "categories": categories, + "witnesses": witnesses, + "unjudged_observed_count": len(unjudged_observed), + "unjudged_observed": unjudged_observed, + "passed": recall_denominator > 0 + and confusion["fp"] == 0 + and confusion["fn"] == 0, + "ground_truth_boundary": ( + "Only explicit judgments enter TP/FP/FN/TN; unjudged observed pairs are retained " + "but excluded because natural-repository ground truth is incomplete." + ), + } + + +def score_ranked_relevance( + ranked_items: list[Any], + judgments: list[dict[str, Any]], + *, + cutoff: int = 5, +) -> dict[str, Any]: + """Score a bounded ranking against explicit graded substring judgments.""" + if cutoff <= 0: + raise ValueError("relevance cutoff must be positive") + valid_judgments = [ + { + "expected": str(item["expected_substring"]), + "required": [ + str(value) + for value in item.get("required_substrings", []) + if isinstance(value, str) and value + ], + "grade": float(item["relevance"]), + } + for item in judgments + if isinstance(item, dict) + and isinstance(item.get("expected_substring"), str) + and item["expected_substring"] + and isinstance(item.get("relevance"), (int, float)) + and float(item["relevance"]) > 0 + ] + all_relevance: list[float | int] = [] + for ranked_item in ranked_items: + serialized = json.dumps(ranked_item, separators=(",", ":"), sort_keys=True) + relevance = max( + ( + item["grade"] + for item in valid_judgments + if item["expected"] in serialized + and all(required in serialized for required in item["required"]) + ), + default=0.0, + ) + all_relevance.append(int(relevance) if relevance.is_integer() else relevance) + first_relevant_rank = next( + ( + index + for index, relevance in enumerate(all_relevance, start=1) + if relevance > 0 + ), + None, + ) + matched_relevance = all_relevance[:cutoff] + + def discounted_gain(grades: list[float | int]) -> float: + return sum( + (2.0 ** float(relevance) - 1.0) / math.log2(position + 1) + for position, relevance in enumerate(grades, start=1) + ) + + dcg = discounted_gain(matched_relevance) + ideal_relevance = sorted((item["grade"] for item in valid_judgments), reverse=True)[ + :cutoff + ] + idcg = discounted_gain(ideal_relevance) + ndcg = dcg / idcg if idcg > 0 else None + result = { + "cutoff": cutoff, + "judgment_count": len(valid_judgments), + "first_relevant_rank": first_relevant_rank, + "reciprocal_rank": 1.0 / first_relevant_rank if first_relevant_rank else 0.0, + "hit_at_1": first_relevant_rank == 1, + "hit_at_5": first_relevant_rank is not None and first_relevant_rank <= 5, + "dcg": dcg, + "ideal_dcg": idcg, + "ndcg": ndcg, + "matched_relevance": matched_relevance, + } + result[f"dcg_at_{cutoff}"] = dcg + result[f"ideal_dcg_at_{cutoff}"] = idcg + result[f"ndcg_at_{cutoff}"] = ndcg + return result + + +def score_quality_oracles( + oracles: dict[str, Any], + expectations: dict[str, Any], +) -> dict[str, Any]: + """Attach auditable per-oracle verdicts and summarize applicable checks.""" + applicable_count = 0 + passed_count = 0 + reciprocal_rank_total = 0.0 + hit_at_1_count = 0 + hit_at_5_count = 0 + ndcg_total = 0.0 + ndcg_applicable_count = 0 + for name, result in oracles.items(): + if not isinstance(result, dict): + continue + expectation = expectations.get(name, (None, "no quality criterion")) + graded = isinstance(expectation, dict) + if graded: + criterion = str(expectation.get("criterion") or "no quality criterion") + judgments = expectation.get("judgments") + judgments = judgments if isinstance(judgments, list) else [] + cutoff = expectation.get("cutoff", 5) + cutoff = int(cutoff) if isinstance(cutoff, int) else 5 + positive_judgments = [ + item + for item in judgments + if isinstance(item, dict) + and isinstance(item.get("expected_substring"), str) + and isinstance(item.get("relevance"), (int, float)) + and float(item["relevance"]) > 0 + ] + expected = ( + str( + max(positive_judgments, key=lambda item: float(item["relevance"]))[ + "expected_substring" + ] + ) + if positive_judgments + else None + ) + required_substrings = ( + list( + max( + positive_judgments, + key=lambda item: float(item["relevance"]), + ).get("required_substrings", []) + ) + if positive_judgments + else [] + ) + else: + expected, criterion = expectation + judgments = [] + cutoff = 5 + required_substrings = [] + applicable = bool(judgments) if graded else expected is not None + passed = False + rank: int | None = None + returned_count: int | None = None + ndcg: float | None = None + if applicable: + applicable_count += 1 + response = result.get("response") + ranked_items = ( + response.get("results") + if isinstance(response, dict) + and isinstance(response.get("results"), list) + else response + if isinstance(response, list) + else [response] + ) + returned_count = len(ranked_items) + if graded: + ranking = score_ranked_relevance(ranked_items, judgments, cutoff=cutoff) + rank = ranking["first_relevant_rank"] + reciprocal_rank = float(ranking["reciprocal_rank"]) + ndcg_value = ranking["ndcg"] + ndcg = ( + float(ndcg_value) if isinstance(ndcg_value, (int, float)) else None + ) + passed = bool(ranking["hit_at_5"]) + if ndcg is not None: + ndcg_total += ndcg + ndcg_applicable_count += 1 + else: + passed = expected in json.dumps( + response, separators=(",", ":"), sort_keys=True + ) + for position, item in enumerate(ranked_items, start=1): + if expected in json.dumps( + item, separators=(",", ":"), sort_keys=True + ): + rank = position + break + reciprocal_rank = 1.0 / rank if rank is not None else 0.0 + passed_count += int(passed) + reciprocal_rank_total += reciprocal_rank + hit_at_1_count += int(rank == 1) + hit_at_5_count += int(rank is not None and rank <= 5) + else: + reciprocal_rank = None + result["quality"] = { + "applicable": applicable, + "passed": passed if applicable else None, + "criterion": criterion, + "expected_substring": expected, + "required_substrings": required_substrings, + "rank": rank, + "returned_count": returned_count, + "reciprocal_rank": reciprocal_rank, + "hit_at_1": rank == 1 if applicable else None, + "hit_at_5": rank is not None and rank <= 5 if applicable else None, + "relevance_judgments": len(judgments) if graded else None, + "relevance_cutoff": cutoff if graded else None, + "ndcg_at_5": ndcg if graded and cutoff == 5 else None, + } + mean_reciprocal_rank = ( + reciprocal_rank_total / applicable_count if applicable_count else None + ) + return { + "passed": passed_count == applicable_count, + "passed_count": passed_count, + "applicable_count": applicable_count, + "binary_pass_rate": round(passed_count / applicable_count, 6) + if applicable_count + else None, + "mean_reciprocal_rank": ( + round(mean_reciprocal_rank, 6) if mean_reciprocal_rank is not None else None + ), + "hit_at_1": round(hit_at_1_count / applicable_count, 6) + if applicable_count + else None, + "hit_at_5": round(hit_at_5_count / applicable_count, 6) + if applicable_count + else None, + "mean_ndcg_at_5": ( + round(ndcg_total / ndcg_applicable_count, 6) + if ndcg_applicable_count + else None + ), + "ndcg_applicable_count": ndcg_applicable_count, + "score": round(mean_reciprocal_rank, 6) + if mean_reciprocal_rank is not None + else None, + } + + +def run_self_dogfood_oracles( + transport: str, + binary: Path, + env: dict[str, str], + project: str, + mutation: dict[str, Any], + args: argparse.Namespace, + client: McpClient | None = None, +) -> dict[str, Any]: + marker = mutation.get("marker") + changed_paths = list(mutation.get("changed_paths") or []) + first_changed = changed_paths[0] if changed_paths else "" + oracles: dict[str, Any] = {} + expectations: dict[str, tuple[str | None, str]] = {} + if marker: + search_code_args: dict[str, Any] = { + "project": project, + "pattern": marker, + "limit": 5, + } + if first_changed: + search_code_args["file_pattern"] = Path(first_changed).name + search_code_args["path_filter"] = f"^{re.escape(first_changed)}$" + oracles["marker_search_graph"] = run_tool_call_for_transport( + transport, + binary, + env, + "search_graph", + {"project": project, "name_pattern": marker, "limit": 5}, + args.timeout, + args.include_logs, + client, + ) + expectations["marker_search_graph"] = ( + marker, + "mutated symbol appears in graph search", + ) + oracles["marker_search_code"] = run_tool_call_for_transport( + transport, + binary, + env, + "search_code", + search_code_args, + args.timeout, + args.include_logs, + client, + ) + expectations["marker_search_code"] = ( + marker, + "mutated symbol appears in source search", + ) + if first_changed: + oracles["changed_file_query_graph"] = run_tool_call_for_transport( + transport, + binary, + env, + "query_graph", + { + "project": project, + "query": ( + "MATCH (n) WHERE n.file_path CONTAINS " + f"'{first_changed}' RETURN n.name, n.label, n.file_path LIMIT 10" + ), + }, + args.timeout, + args.include_logs, + client, + ) + expectations["changed_file_query_graph"] = ( + first_changed, + "changed file path appears in graph query", + ) + oracles["scoped_architecture"] = run_tool_call_for_transport( + transport, + binary, + env, + "get_architecture", + {"project": project, "path": first_changed, "aspects": ["all"]}, + args.timeout, + args.include_logs, + client, + ) + expectations["scoped_architecture"] = ( + first_changed, + "changed file path appears in scoped architecture", + ) + route_expected = ( + "/api/pan4-oracle" + if mutation.get("description", "").startswith("HTTP UI handler") + else None + ) + route_arguments: dict[str, Any] = {"project": project, "label": "Route", "limit": 5} + if route_expected: + route_arguments["name_pattern"] = "pan4-oracle" + oracles["route_freshness_probe"] = run_tool_call_for_transport( + transport, + binary, + env, + "search_graph", + route_arguments, + args.timeout, + args.include_logs, + client, + ) + expectations["route_freshness_probe"] = ( + route_expected, + "new route literal appears in route search" + if route_expected + else "route mutation not applicable", + ) + quality = score_quality_oracles(oracles, expectations) + oracles["quality"] = quality + oracles["passed"] = quality["passed"] + return oracles + + +def run_rank_quality_oracles( + transport: str, + binary: Path, + env: dict[str, str], + project: str, + args: argparse.Namespace, + client: McpClient | None = None, +) -> dict[str, Any]: + oracles = { + "central_order_search": run_tool_call_for_transport( + transport, + binary, + env, + "search_graph", + { + "project": project, + "label": "Function", + "name_pattern": "order", + "limit": 10, + }, + args.timeout, + args.include_logs, + client, + ) + } + expectations = { + "central_order_search": { + "criterion": ( + "rank the structurally central order workflow ahead of lexical-only decoys" + ), + "cutoff": 5, + "judgments": [ + {"expected_substring": "zz_order_core", "relevance": 3}, + ], + } + } + quality = score_quality_oracles(oracles, expectations) + oracles["quality"] = quality + oracles["passed"] = quality["passed"] + return oracles + + +def run_dependency_quality_oracles( + transport: str, + binary: Path, + env: dict[str, str], + project: str, + args: argparse.Namespace, + client: McpClient | None = None, +) -> dict[str, Any]: + symbol = "canonicalDependencyAPI" + package_name = "cbmbenchdep" + oracles = { + "dependency_api_search": run_tool_call_for_transport( + transport, + binary, + env, + "search_graph", + { + "project": project, + "label": "Function", + "name_pattern": symbol, + "include_dependencies": True, + "limit": 10, + }, + args.timeout, + args.include_logs, + client, + ) + } + expectations = { + "dependency_api_search": { + "criterion": ( + "retrieve the imported dependency API with dependency, package, and read-only " + "provenance on the same result" + ), + "cutoff": 5, + "judgments": [ + { + "expected_substring": symbol, + "required_substrings": [ + '"source":"dependency"', + f'"package":"{package_name}"', + '"read_only":true', + ], + "relevance": 3, + } + ], + } + } + quality = score_quality_oracles(oracles, expectations) + oracles["quality"] = quality + oracles["passed"] = quality["passed"] + return oracles + + +def run_git_history_quality_oracles( + transport: str, + binary: Path, + env: dict[str, str], + project: str, + args: argparse.Namespace, + client: McpClient | None = None, +) -> dict[str, Any]: + oracles = { + "file_change_coupling": run_tool_call_for_transport( + transport, + binary, + env, + "query_graph", + { + "project": project, + "query": ( + "MATCH (a)-[r:FILE_CHANGES_WITH]->(b) " + "RETURN a.file_path, b.file_path, r.co_changes, r.coupling_score LIMIT 10" + ), + }, + args.timeout, + args.include_logs, + client, + ) + } + quality = score_quality_oracles( + oracles, + { + "file_change_coupling": { + "criterion": ( + "retrieve the declared four-commit alpha.py/beta.py co-change relationship" + ), + "cutoff": 5, + "judgments": [ + { + "expected_substring": "alpha.py", + "required_substrings": ["beta.py", '"4"', '"1.00"'], + "relevance": 3, + } + ], + } + }, + ) + oracles["quality"] = quality + oracles["passed"] = quality["passed"] + return oracles + + +def run_http_links_quality_oracles( + transport: str, + binary: Path, + env: dict[str, str], + project: str, + args: argparse.Namespace, + client: McpClient | None = None, +) -> dict[str, Any]: + oracles = { + "http_call_link": run_tool_call_for_transport( + transport, + binary, + env, + "query_graph", + { + "project": project, + "query": ( + "MATCH (a)-[r:HTTP_CALLS]->(b) " + "WHERE b.name = 'configureRouting' " + "RETURN a.name, b.name, r.url_path, r.confidence LIMIT 10" + ), + }, + args.timeout, + args.include_logs, + client, + ) + } + quality = score_quality_oracles( + oracles, + { + "http_call_link": { + "criterion": ( + "retrieve the fetch_order HTTP client link to the declared order route" + ), + "cutoff": 5, + "judgments": [ + { + "expected_substring": "/api/cbmbench-orders/42", + "required_substrings": ["fetch_order", "configureRouting"], + "relevance": 3, + } + ], + } + }, + ) + oracles["quality"] = quality + oracles["passed"] = quality["passed"] + return oracles + + +def observed_pairs_from_query_response( + tool_result: dict[str, Any], +) -> list[dict[str, Any]]: + response = tool_result.get("response") + if not isinstance(response, dict): + return [] + columns = response.get("columns") + rows = response.get("rows") + if not isinstance(columns, list) or not isinstance(rows, list): + return [] + column_names = [str(value) for value in columns] + observed: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, list) or len(row) < 2: + continue + score = row[2] if len(row) > 2 else None + if isinstance(score, str): + with suppress(ValueError): + score = float(score) + values = { + column_names[index]: value + for index, value in enumerate(row) + if index < len(column_names) + } + observed.append( + { + "source": str(row[0]), + "target": str(row[1]), + "score": score, + "source_path": row[3] if len(row) > 3 else None, + "target_path": row[4] if len(row) > 4 else None, + "row": values, + } + ) + return observed + + +def compare_pair_oracle_outputs( + incremental: dict[str, Any], fresh: dict[str, Any] +) -> dict[str, Any]: + def canonical(output: dict[str, Any]) -> set[tuple[str, str, float | str | None]]: + result: set[tuple[str, str, float | str | None]] = set() + for item in output.get("observed_pairs", []): + source, target = canonical_pair(item.get("source"), item.get("target")) + result.add((source, target, item.get("score"))) + return result + + incremental_pairs = canonical(incremental) + fresh_pairs = canonical(fresh) + + def render( + values: set[tuple[str, str, float | str | None]], + ) -> list[dict[str, Any]]: + return [ + {"source": source, "target": target, "score": score} + for source, target, score in sorted(values) + ] + + return { + "passed": incremental_pairs == fresh_pairs, + "incremental_only": render(incremental_pairs - fresh_pairs), + "fresh_only": render(fresh_pairs - incremental_pairs), + "incremental_pair_count": len(incremental_pairs), + "fresh_pair_count": len(fresh_pairs), + } + + +def evaluate_pair_incremental_policy( + config_overrides: dict[str, str], + incremental_index: dict[str, Any], + incremental_oracles: dict[str, Any], + canonical_graph: dict[str, Any], + pair_equality: dict[str, Any], +) -> dict[str, Any]: + explicit_policy = config_overrides.get("incremental_derived_results_refresh") + policy = explicit_policy or DERIVED_REFRESH_CANDIDATE_DEFAULT + policy_source = "explicit_override" if explicit_policy else "candidate_default" + warnings = ( + incremental_oracles.get("edge_query", {}) + .get("response", {}) + .get("warnings", []) + ) + warnings = warnings if isinstance(warnings, list) else [] + stale_warning_present = any( + isinstance(warning, str) and "semantic_edges derived view is stale" in warning + for warning in warnings + ) + pair_freshness_met = bool( + incremental_oracles.get("passed") and pair_equality.get("passed") + ) + immediate_freshness_met = bool(pair_freshness_met and canonical_graph.get("equal")) + if explicit_policy: + immediate_freshness_expected: bool | None = policy == "at_publish" + policy_conformance_met = ( + immediate_freshness_met and not stale_warning_present + if immediate_freshness_expected + else immediate_freshness_met or stale_warning_present + ) + observed_behavior = ( + "immediate_full_freshness" + if immediate_freshness_met and not stale_warning_present + else "deferred_with_warning" + if stale_warning_present + else "unreported_stale" + ) + elif stale_warning_present: + immediate_freshness_expected = False + policy_conformance_met = True + observed_behavior = "deferred_with_warning" + elif pair_freshness_met: + immediate_freshness_expected = True + policy_conformance_met = True + observed_behavior = ( + "immediate_full_freshness" + if immediate_freshness_met + else "immediate_pair_freshness" + ) + else: + immediate_freshness_expected = None + policy_conformance_met = False + observed_behavior = "unreported_stale" + return { + "policy": policy, + "policy_source": policy_source, + "observed_behavior": observed_behavior, + "publish_kind": incremental_index.get("publish_kind"), + "immediate_freshness_expected": immediate_freshness_expected, + "pair_freshness_met": pair_freshness_met, + "immediate_freshness_met": immediate_freshness_met, + "stale_warning_present": stale_warning_present, + "policy_conformance_met": policy_conformance_met, + "interpretation": ( + "at_publish policy requires canonical fresh semantic/similarity results" + if explicit_policy == "at_publish" + else "explicit deferred policy requires a stale warning or canonical freshness" + if explicit_policy + else "candidate default is classified from observed pair freshness and warnings" + ), + } + + +def run_relation_quality_oracles( + transport: str, + binary: Path, + env: dict[str, str], + project: str, + fixture: dict[str, Any], + args: argparse.Namespace, + client: McpClient | None = None, +) -> dict[str, Any]: + relationship = str(fixture["relationship"]) + score_property = str(fixture["score_property"]) + marker = str(fixture["query_name_marker"]) + query = ( + f"MATCH (a)-[r:{relationship}]->(b) " + f"WHERE a.name CONTAINS '{marker}' OR b.name CONTAINS '{marker}' " + f"RETURN a.name, b.name, r.{score_property}, a.file_path, b.file_path LIMIT 1000" + ) + edge_query = run_tool_call_for_transport( + transport, + binary, + env, + "query_graph", + { + "project": project, + "query": query, + "format": "json", + "max_output_bytes": 1024 * 1024, + }, + args.timeout, + args.include_logs, + client, + ) + observed_pairs = observed_pairs_from_query_response(edge_query) + pair_classification = score_pair_classification( + observed_pairs, + list(fixture["judgments"]), + ) + true_positive_witnesses = pair_classification["witnesses"]["tp"] + score_witness_count = sum( + 1 + for witness in true_positive_witnesses + if isinstance(witness.get("observed"), dict) + and isinstance(witness["observed"].get("score"), (int, float)) + ) + score_coverage = ( + score_witness_count / len(true_positive_witnesses) + if true_positive_witnesses + else None + ) + response = edge_query.get("response") + response_quality = { + "correctness": pair_classification["passed"], + "relevance": pair_classification["precision"], + "completeness": pair_classification["recall"], + "actionable_witness_score_coverage": score_coverage, + "protocol_shape_valid": ( + isinstance(response, dict) + and isinstance(response.get("columns"), list) + and isinstance(response.get("rows"), list) + ), + "truncated": response.get("truncated") if isinstance(response, dict) else None, + "elapsed_ms": edge_query.get("elapsed_ms"), + "response_bytes": edge_query.get("response_bytes"), + "response_token_estimate": edge_query.get("response_token_estimate"), + "hard_gate": ( + pair_classification["passed"] + and score_coverage == 1.0 + and isinstance(response, dict) + and isinstance(response.get("rows"), list) + and not bool(response.get("truncated")) + ), + } + return { + "relationship": relationship, + "edge_query": edge_query, + "observed_pairs": observed_pairs, + "pair_classification": pair_classification, + "response_quality": response_quality, + "passed": response_quality["hard_gate"], + } + + +def run_index_for_transport( + transport: str, + binary: Path, + env: dict[str, str], + repo_dir: Path, + timeout: int, + include_logs: bool, + client: McpClient | None = None, + index_mode: str = "fast", +) -> dict[str, Any]: + if transport == "mcp": + if client is None: + raise RuntimeError("MCP transport requires an active client") + return run_index_mcp(client, repo_dir, include_logs, index_mode) + return run_index(binary, env, repo_dir, timeout, include_logs, index_mode) + + +def run_pair_quality_lifecycle( + args: argparse.Namespace, + binary: Path, + case_env: dict[str, str], + repo_dir: Path, + cache_dir: Path, + work_root: Path, + fixture: dict[str, Any], +) -> dict[str, Any]: + run_config_set(binary, case_env, "incremental_reindex", "always", args.timeout) + if args.transport == "mcp": + with McpClient(binary, case_env, args.timeout) as client: + initial_index = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + project = str(initial_index.get("response", {}).get("project") or "repo") + initial_oracles = run_relation_quality_oracles( + args.transport, binary, case_env, project, fixture, args, client + ) + initial_graph_fingerprint = stable_graph_fingerprint( + find_project_db(cache_dir), project + ) + mutation = apply_pair_quality_mutation(repo_dir, fixture) + incremental_index = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + post_fixture = {**fixture, "judgments": mutation["post_judgments"]} + incremental_oracles = run_relation_quality_oracles( + args.transport, binary, case_env, project, post_fixture, args, client + ) + else: + initial_index = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + project = str(initial_index.get("response", {}).get("project") or "repo") + initial_oracles = run_relation_quality_oracles( + args.transport, binary, case_env, project, fixture, args + ) + initial_graph_fingerprint = stable_graph_fingerprint( + find_project_db(cache_dir), project + ) + mutation = apply_pair_quality_mutation(repo_dir, fixture) + incremental_index = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + post_fixture = {**fixture, "judgments": mutation["post_judgments"]} + incremental_oracles = run_relation_quality_oracles( + args.transport, binary, case_env, project, post_fixture, args + ) + + incremental_db = find_project_db(cache_dir) + incremental_snapshot = work_root / "incremental.db" + copy_sqlite_snapshot(incremental_db, incremental_snapshot) + + fresh_cache = work_root / "fresh-cache" + fresh_cache.mkdir(parents=True, exist_ok=True) + fresh_env = build_env(fresh_cache, args.product_environment) + apply_rank_refresh_override(binary, fresh_env, args.rank_refresh, args.timeout) + apply_config_overrides(binary, fresh_env, args.config_overrides, args.timeout) + if args.transport == "mcp": + with McpClient(binary, fresh_env, args.timeout) as client: + fresh_index = run_index_for_transport( + args.transport, + binary, + fresh_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + fresh_project = str( + fresh_index.get("response", {}).get("project") or project + ) + fresh_oracles = run_relation_quality_oracles( + args.transport, + binary, + fresh_env, + fresh_project, + post_fixture, + args, + client, + ) + else: + fresh_index = run_index_for_transport( + args.transport, + binary, + fresh_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + fresh_project = str(fresh_index.get("response", {}).get("project") or project) + fresh_oracles = run_relation_quality_oracles( + args.transport, binary, fresh_env, fresh_project, post_fixture, args + ) + fresh_db = find_project_db(fresh_cache) + incremental_graph_fingerprint = stable_graph_fingerprint( + incremental_snapshot, project + ) + fresh_graph_fingerprint = stable_graph_fingerprint(fresh_db, fresh_project) + canonical_graph = compare_canonical_graph(incremental_snapshot, fresh_db, project) + pair_equality = compare_pair_oracle_outputs(incremental_oracles, fresh_oracles) + incremental_policy = evaluate_pair_incremental_policy( + args.config_overrides, + incremental_index, + incremental_oracles, + canonical_graph, + pair_equality, + ) + return { + "project": project, + "initial_index": initial_index, + "initial_oracles": initial_oracles, + "mutation": mutation, + "incremental_index": incremental_index, + "incremental_oracles": incremental_oracles, + "fresh_index": fresh_index, + "fresh_oracles": fresh_oracles, + "graph_fingerprints": { + "initial": initial_graph_fingerprint, + "incremental": incremental_graph_fingerprint, + "fresh": fresh_graph_fingerprint, + }, + "canonical_graph": canonical_graph, + "pair_equality": pair_equality, + "incremental_policy": incremental_policy, + "policy_conformance_met": incremental_policy["policy_conformance_met"], + "quality_target_met": bool( + initial_oracles.get("passed") + and incremental_oracles.get("passed") + and fresh_oracles.get("passed") + and canonical_graph.get("equal") + and pair_equality.get("passed") + ), + } + + +def run_capability_quality( + args: argparse.Namespace, binary: Path +) -> tuple[dict[str, Any], int]: + capability = args.capability_quality + if capability not in CAPABILITY_QUALITY_CASES: + raise ValueError(f"unsupported capability quality case: {capability}") + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix=f"cbm-quality-{capability}-")) + ) + repo_dir = work_root / "repo" + cache_dir = work_root / "cache" + repo_dir.mkdir(parents=True, exist_ok=True) + cache_dir.mkdir(parents=True, exist_ok=True) + case_env = build_env(cache_dir, args.product_environment) + report: dict[str, Any] = { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "binary": str(binary), + "binary_metadata": binary_metadata(binary), + "work_root": str(work_root), + "mode": "capability_quality", + "parameters": { + "capability": capability, + "rank_refresh": args.rank_refresh, + "rank_refresh_override_applied": ( + args.rank_refresh != RANK_REFRESH_CANDIDATE_DEFAULT + ), + "index_mode": args.index_mode, + "capability_applicability": index_mode_capability_applicability( + args.index_mode + ), + "config_profile": args.config_profile, + "config_overrides": args.config_overrides, + "configuration_environment": benchmark_environment_policy( + args.product_environment + ), + "transport": args.transport, + "timeout": args.timeout, + "quality_background_repo": args.quality_background_repo or None, + "quality_background_revision": ( + (args.quality_background_revision or "HEAD") + if args.quality_background_repo + else None + ), + }, + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + "cases": [], + } + exit_code = 1 + try: + background = None + if args.quality_background_revision and not args.quality_background_repo: + raise ValueError( + "--quality-background-revision requires --quality-background-repo" + ) + if args.quality_background_repo: + if capability not in {"similarity", "semantic_edges"}: + raise ValueError( + "quality background repository is supported only for similarity and semantic_edges" + ) + background = copy_git_revision_to_dir( + Path(args.quality_background_repo).expanduser(), + repo_dir, + args.quality_background_revision or "HEAD", + args.timeout, + excluded_prefixes=("benchmarks/semantic-pairs-v1/",), + ) + fixture_factory = { + "rank": create_rank_quality_repo, + "dependencies": create_dependency_quality_repo, + "similarity": create_similarity_quality_repo, + "semantic_edges": create_semantic_edges_quality_repo, + "git_history": create_git_history_quality_repo, + "http_links": create_http_links_quality_repo, + }[capability] + fixture = fixture_factory(repo_dir) + apply_rank_refresh_override(binary, case_env, args.rank_refresh, args.timeout) + apply_config_overrides(binary, case_env, args.config_overrides, args.timeout) + lifecycle = None + if capability in {"similarity", "semantic_edges"}: + lifecycle = run_pair_quality_lifecycle( + args, binary, case_env, repo_dir, cache_dir, work_root, fixture + ) + indexed = lifecycle["initial_index"] + project = lifecycle["project"] + oracles = lifecycle["initial_oracles"] + elif args.transport == "mcp": + with McpClient(binary, case_env, args.timeout) as client: + indexed = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + project = str(indexed.get("response", {}).get("project") or "repo") + oracle_runner = { + "rank": run_rank_quality_oracles, + "dependencies": run_dependency_quality_oracles, + "git_history": run_git_history_quality_oracles, + "http_links": run_http_links_quality_oracles, + }[capability] + oracles = oracle_runner( + args.transport, binary, case_env, project, args, client + ) + else: + indexed = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + project = str(indexed.get("response", {}).get("project") or "repo") + oracle_runner = { + "rank": run_rank_quality_oracles, + "dependencies": run_dependency_quality_oracles, + "git_history": run_git_history_quality_oracles, + "http_links": run_http_links_quality_oracles, + }[capability] + oracles = oracle_runner(args.transport, binary, case_env, project, args) + case = { + "scenario": f"{capability}_quality", + "project": project, + "fixture": fixture, + "background_repository": background, + "initial_fast_full": indexed, + "oracles": oracles, + "pair_lifecycle": lifecycle, + "execution_passed": True, + "quality_target_met": ( + bool(lifecycle["quality_target_met"]) + if lifecycle is not None + else bool(oracles.get("passed")) + ), + "passed": True, + } + report["cases"].append(case) + report["derived"] = { + "passed": True, + "quality_target_met": case["quality_target_met"], + "case_count": 1, + } + exit_code = 0 + except Exception as exc: + record_report_error(report, exc) + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + return report, exit_code + + +def run_matrix_case( + scenario: str, + binary: Path, + env: dict[str, str], + case_root: Path, + args: argparse.Namespace, +) -> dict[str, Any]: + repo_dir = case_root / "repo" + cache_dir = case_root / "cache" + case_root.mkdir(parents=True, exist_ok=True) + if scenario not in MATRIX_REAL_REPO_SCENARIOS: + repo_dir.mkdir(parents=True, exist_ok=True) + cache_dir.mkdir(parents=True, exist_ok=True) + case_env = dict(env) + case_env["CBM_CACHE_DIR"] = str(cache_dir) + run_config_set(binary, case_env, "incremental_reindex", "always", args.timeout) + apply_rank_refresh_override(binary, case_env, args.rank_refresh, args.timeout) + apply_config_overrides(binary, case_env, args.config_overrides, args.timeout) + + scenario_metadata = prepare_matrix_scenario( + scenario, repo_dir, args.files, args.functions_per_file, args, case_root + ) + if args.transport == "mcp": + with McpClient(binary, case_env, args.timeout) as client: + initial = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + changed_paths = mutate_matrix_scenario( + scenario, repo_dir, args.functions_per_file + ) + incremental = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + else: + initial = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + changed_paths = mutate_matrix_scenario( + scenario, repo_dir, args.functions_per_file + ) + incremental = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + + project_db = find_project_db(cache_dir) + project = str(incremental.get("response", {}).get("project") or project_db.stem) + incremental_snapshot = case_root / "incremental.db" + copy_sqlite_snapshot(project_db, incremental_snapshot) + removed_dbs = remove_project_dbs(cache_dir) + + if args.transport == "mcp": + with McpClient(binary, case_env, args.timeout) as client: + full_rebuild = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + else: + full_rebuild = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + + full_db = find_project_db(cache_dir) + canonical = compare_canonical_graph(incremental_snapshot, full_db, project) + incremental_reason = incremental.get("exact_reason") + publish_kind = incremental.get("publish_kind") + active_overlay = None + if publish_kind == PUBLISH_INCREMENTAL_OVERLAY: + active_overlay = compare_active_overlay_graph( + incremental_snapshot, full_db, project + ) + graph_gate = graph_gate_for_publish_kind( + canonical, str(publish_kind or ""), active_overlay=active_overlay + ) + configured_cap = args.config_overrides.get("incremental_exact_max_affected_paths") + try: + exact_cap = int(configured_cap) if configured_cap is not None else None + except ValueError: + exact_cap = None + frontier_gate = frontier_coverage_gate( + scenario_metadata, incremental, exact_cap=exact_cap + ) + explicit_route = is_explicit_incremental_route(publish_kind, incremental_reason) + passed = ( + bool(graph_gate.get("passed")) + and bool(frontier_gate.get("passed")) + and explicit_route + ) + speedup = max(1, int(full_rebuild["elapsed_ms"])) / max( + 1, int(incremental["elapsed_ms"]) + ) + return { + "scenario": scenario, + "project": project, + "changed_paths": changed_paths, + "scenario_metadata": scenario_metadata, + "removed_project_dbs": removed_dbs, + "initial_fast_full": initial, + "incremental": incremental, + "fresh_fast_full_after_change": full_rebuild, + "canonical_graph": canonical, + "active_overlay_graph": active_overlay, + "graph_gate": graph_gate, + "frontier_coverage_gate": frontier_gate, + "explicit_exact_or_fallback": explicit_route, + "explicit_incremental_route": explicit_route, + "exact_reason": incremental_reason, + "speedup_full_rebuild_over_incremental": speedup, + "passed": passed, + } + + +def run_matrix(args: argparse.Namespace, binary: Path) -> tuple[dict[str, Any], int]: + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix="cbm-incr-matrix-")) + ) + work_root.mkdir(parents=True, exist_ok=True) + scenarios = [ + item.strip() for item in args.matrix_scenarios.split(",") if item.strip() + ] + report: dict[str, Any] = { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "binary": str(binary), + "binary_metadata": binary_metadata(binary), + "work_root": str(work_root), + "mode": "matrix", + "parameters": { + "files": args.files, + "functions_per_file": args.functions_per_file, + "frontier_files": args.frontier_files, + "rank_refresh": args.rank_refresh, + "rank_refresh_override_applied": ( + args.rank_refresh != RANK_REFRESH_CANDIDATE_DEFAULT + ), + "index_mode": args.index_mode, + "capability_applicability": index_mode_capability_applicability( + args.index_mode + ), + "config_profile": args.config_profile, + "config_overrides": args.config_overrides, + "configuration_environment": benchmark_environment_policy( + args.product_environment + ), + "timeout": args.timeout, + "transport": args.transport, + "scenarios": scenarios, + }, + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + "cases": [], + } + exit_code = 1 + try: + base_env = build_env(work_root / "cache-base", args.product_environment) + for scenario in scenarios: + case = run_matrix_case( + scenario, binary, base_env, work_root / scenario, args + ) + report["cases"].append(case) + report["derived"] = { + "passed": all(bool(case.get("passed")) for case in report["cases"]), + "case_count": len(report["cases"]), + } + exit_code = 0 if report["derived"]["passed"] else 1 + except Exception as exc: + record_report_error(report, exc) + exit_code = 1 + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + return report, exit_code + + +def run_self_dogfood_case( + scenario: str, + source_repo: Path, + binary: Path, + case_root: Path, + args: argparse.Namespace, + revision: str, +) -> dict[str, Any]: + cache_dir = case_root / SELF_DOGFOOD_CACHE_SUBDIR + cache_dir.mkdir(parents=True, exist_ok=True) + repo_dir = create_self_dogfood_worktree( + source_repo, case_root, args.timeout, revision + ) + case_env = build_env(cache_dir, args.product_environment) + cleanup: dict[str, Any] = {"requested": not args.keep_work_root, "removed": False} + result: dict[str, Any] | None = None + try: + run_config_set(binary, case_env, "incremental_reindex", "always", args.timeout) + apply_rank_refresh_override(binary, case_env, args.rank_refresh, args.timeout) + apply_config_overrides(binary, case_env, args.config_overrides, args.timeout) + if args.transport == "mcp": + with McpClient(binary, case_env, args.timeout) as client: + initial = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + project_db = find_project_db(cache_dir) + project = str( + initial.get("response", {}).get("project") or project_db.stem + ) + indexed_query_probe = measure_indexed_query_probes_for_transport( + args.transport, + binary, + case_env, + args.indexed_query_tool, + args.indexed_query_probes, + project, + args.timeout, + args.include_logs, + client, + ) + mutation = mutate_self_dogfood_scenario(scenario, repo_dir) + incremental = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + project = str(incremental.get("response", {}).get("project") or project) + oracles = run_self_dogfood_oracles( + args.transport, binary, case_env, project, mutation, args, client + ) + else: + initial = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + project_db = find_project_db(cache_dir) + project = str(initial.get("response", {}).get("project") or project_db.stem) + indexed_query_probe = measure_indexed_query_probes_for_transport( + args.transport, + binary, + case_env, + args.indexed_query_tool, + args.indexed_query_probes, + project, + args.timeout, + args.include_logs, + ) + mutation = mutate_self_dogfood_scenario(scenario, repo_dir) + incremental = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + project = str(incremental.get("response", {}).get("project") or project) + oracles = run_self_dogfood_oracles( + args.transport, binary, case_env, project, mutation, args + ) + + incremental_snapshot = case_root / "incremental.db" + copy_sqlite_snapshot(project_db, incremental_snapshot) + removed_dbs = remove_project_dbs(cache_dir) + if args.transport == "mcp": + with McpClient(binary, case_env, args.timeout) as client: + full_rebuild = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + client, + index_mode=args.index_mode, + ) + else: + full_rebuild = run_index_for_transport( + args.transport, + binary, + case_env, + repo_dir, + args.timeout, + args.include_logs, + index_mode=args.index_mode, + ) + full_db = find_project_db(cache_dir) + canonical = compare_canonical_graph(incremental_snapshot, full_db, project) + stale_views = sorted( + set(declared_stale_views(oracles)) + | set(persisted_stale_views(incremental_snapshot, project)) + ) + freshness_scoped = compare_graph_excluding_declared_stale_views( + incremental_snapshot, full_db, project, stale_views + ) + publish_kind = incremental.get("publish_kind") + incremental_reason = incremental.get("exact_reason") + active_overlay = None + if publish_kind == PUBLISH_INCREMENTAL_OVERLAY: + active_overlay = compare_active_overlay_graph( + incremental_snapshot, full_db, project + ) + explicit_route = is_explicit_incremental_route(publish_kind, incremental_reason) + speedup = max(1, int(full_rebuild["elapsed_ms"])) / max( + 1, int(incremental["elapsed_ms"]) + ) + graph_gate = graph_gate_for_publish_kind( + canonical, + str(publish_kind or ""), + bool(oracles.get("passed")), + active_overlay=active_overlay, + freshness_scoped=freshness_scoped, + ) + passed = ( + bool(graph_gate.get("passed")) + and explicit_route + and bool(oracles.get("passed")) + ) + result = { + "scenario": scenario, + "project": project, + "repo_dir": str(repo_dir), + "mutation": mutation, + "removed_project_dbs": removed_dbs, + "initial_fast_full": initial, + "indexed_query_probe": indexed_query_probe, + "incremental": incremental, + "fresh_fast_full_after_change": full_rebuild, + "canonical_graph": canonical, + "freshness_scoped_graph": freshness_scoped, + "active_overlay_graph": active_overlay, + "graph_gate": graph_gate, + "oracles": oracles, + "explicit_incremental_route": explicit_route, + "exact_reason": incremental_reason, + "speedup_full_rebuild_over_incremental": speedup, + "passed": passed, + } + finally: + if not args.keep_work_root: + cleanup = remove_self_dogfood_worktree(source_repo, repo_dir, args.timeout) + if cache_dir.exists() and not args.keep_work_root: + shutil.rmtree(cache_dir, ignore_errors=True) + cleanup["cache_removed"] = not cache_dir.exists() + cleanup["case_root_removed"] = False + if not args.keep_work_root and case_root.exists(): + shutil.rmtree(case_root, ignore_errors=True) + cleanup["case_root_removed"] = not case_root.exists() + if result is None: + raise RuntimeError(f"self-dogfood case did not produce a result: {scenario}") + result["cleanup"] = cleanup + return result + + +def self_dogfood_scope_manifest( + source_revision: str, + source_tree: str, + scenarios: list[str], +) -> dict[str, Any]: + return { + "workload": "self_dogfood", + "input_tree": { + "revision": source_revision, + "tree": source_tree, + "identity_source": "git_commit_and_tree_objects", + }, + "mutation_policy": { + "kind": "deterministic_named_mutations", + "source": "mutate_self_dogfood_scenario", + "scenarios": [ + { + "name": scenario, + "changed_paths": list(SELF_DOGFOOD_SCENARIO_PATHS[scenario]), + } + for scenario in scenarios + ], + }, + "functions_per_file": { + "status": "not_applicable", + "reason": "real_repository_workload", + }, + "generated_source_policy": { + "kind": "exact_git_tree_plus_deterministic_mutation", + "source": "create_self_dogfood_worktree_and_mutate_self_dogfood_scenario", + }, + } + + +def self_dogfood_cache_manifest(args: argparse.Namespace) -> dict[str, Any]: + dependency_state = ( + "disabled_by_explicit_config" + if args.config_overrides.get("auto_index_deps") == "false" + else "isolated_under_harness_cache_root" + ) + return { + "process": { + "state": ( + "persistent_within_lifecycle" + if args.transport == "mcp" + else "new_per_tool_call" + ), + "source": "transport_contract", + }, + "repository_graph": { + "initial_state": "empty_harness_owned_cache", + "reset_procedure": "remove_project_dbs_before_clean_rebuild", + }, + "dependency_artifacts": { + "state": dependency_state, + "cache_scope": "isolated_per_benchmark_case", + }, + "os_page_cache": { + "state": "uncontrolled_by_harness", + "measurement_policy": "record_and_compare_only_on_identical_host_manifest", + }, + "sqlite_page_cache": { + "state": "candidate_process_local_default", + "database_reset": "project_db_files_removed_before_clean_rebuild", + }, + "parser_compiler_cache": { + "state": "not_applicable", + "reason": "tree_sitter_parsers_are_compiled_into_candidate_binary", + }, + "fixture_cache": { + "state": "not_applicable", + "reason": "fresh_detached_git_worktree_per_case", + }, + } + + +def run_self_dogfood( + args: argparse.Namespace, binary: Path +) -> tuple[dict[str, Any], int]: + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix="cbm-self-dogfood-")) + ) + work_root.mkdir(parents=True, exist_ok=True) + source_repo = resolve_git_repo_root(Path(args.repo_root), args.timeout) + source_revision = command_stdout( + ["git", "rev-parse", f"{args.repo_revision}^{{commit}}"], + args.timeout, + source_repo, + ) + source_tree = command_stdout( + ["git", "rev-parse", f"{source_revision}^{{tree}}"], + args.timeout, + source_repo, + ) + scenarios = [ + item.strip() for item in args.self_dogfood_scenarios.split(",") if item.strip() + ] + for scenario in scenarios: + if scenario not in SELF_DOGFOOD_SCENARIO_PATHS: + raise ValueError(f"unknown self-dogfood scenario: {scenario}") + repository_background = { + "repo": str(source_repo), + "revision": source_revision, + "tree": source_tree, + "source_dirty_status_short": command_stdout( + ["git", "status", "--short"], args.timeout, source_repo + ), + "copy_policy": "detached_worktree_from_exact_commit", + } + report: dict[str, Any] = { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "binary": str(binary), + "binary_metadata": binary_metadata(binary), + "work_root": str(work_root), + "source_repo": str(source_repo), + "source_git": git_metadata(source_repo, args.timeout), + "repository_background": repository_background, + "mode": "self_dogfood", + "scope": self_dogfood_scope_manifest( + source_revision, + source_tree, + scenarios, + ), + "cache": self_dogfood_cache_manifest(args), + "parameters": { + "rank_refresh": args.rank_refresh, + "rank_refresh_override_applied": ( + args.rank_refresh != RANK_REFRESH_CANDIDATE_DEFAULT + ), + "index_mode": args.index_mode, + "capability_applicability": index_mode_capability_applicability( + args.index_mode + ), + "config_profile": args.config_profile, + "config_overrides": args.config_overrides, + "configuration_environment": benchmark_environment_policy( + args.product_environment + ), + "timeout": args.timeout, + "transport": args.transport, + "scenarios": scenarios, + "repo_revision": source_revision, + "indexed_query_probes": args.indexed_query_probes, + "indexed_query_tool": args.indexed_query_tool, + }, + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + "cases": [], + } + exit_code = 1 + try: + for scenario in scenarios: + case = run_self_dogfood_case( + scenario, + source_repo, + binary, + work_root / scenario, + args, + source_revision, + ) + report["cases"].append(case) + report["derived"] = { + "passed": all(bool(case.get("passed")) for case in report["cases"]), + "case_count": len(report["cases"]), + } + exit_code = 0 if report["derived"]["passed"] else 1 + except Exception as exc: + record_report_error(report, exc) + exit_code = 1 + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + return report, exit_code + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run one isolated benchmark workload and emit report JSON plus canonical " + "fact tables. The default workload compares exact fast-mode incremental " + "indexing with a fresh full rebuild." + ) + ) + parser.add_argument("--binary", default="build/c/codebase-memory-mcp") + parser.add_argument( + "--describe-terms", + choices=("json", "markdown"), + default="", + help=( + "Print the canonical benchmark terminology registry or generated Markdown " + f"(version {BENCHMARK_TERMINOLOGY_VERSION}; " + "benchmarks/terminology.json), then exit." + ), + ) + parser.add_argument( + "--candidate-revision", + default="", + help=( + "Commit-ish identifying the candidate binary for a standalone run. It is " + "resolved to a full commit in the binary checkout; experiment runs supply the " + "immutable cell revision automatically." + ), + ) + parser.add_argument( + "--build-metadata-json", + default="", + metavar="JSON", + help=( + "Standalone build metadata object, for example compiler, target, CFLAGS, " + "optimization, sanitizer, and feature flags. Experiment runs supply this from " + "the immutable cell automatically." + ), + ) + parser.add_argument("--work-root", default="") + parser.add_argument("--repo-root", default=".") + parser.add_argument("--out", default="") + parser.add_argument( + "--facts-dir", + default="", + help=( + "Write versioned runs.json, steps.jsonl, results.json, artifacts.json, " + f"and manifest.json facts using {BENCHMARK_FACT_SCHEMA}. Defaults to the " + "experiment artifact directory or .facts." + ), + ) + parser.add_argument( + "--import-report", + default="", + metavar="LEGACY-REPORT.json", + help=( + "Normalize a retained older benchmark report into canonical fact tables. " + "Missing historical metadata is marked unknown; no benchmark binary runs. " + "Requires --facts-dir." + ), + ) + parser.add_argument("--files", type=int, default=DEFAULT_FILE_COUNT) + parser.add_argument( + "--functions-per-file", type=int, default=DEFAULT_FUNCTIONS_PER_FILE + ) + parser.add_argument("--changed-files", type=int, default=DEFAULT_CHANGED_FILES) + parser.add_argument("--min-speedup", type=float, default=DEFAULT_MIN_SPEEDUP) + parser.add_argument( + "--index-mode", + choices=INDEX_MODES, + default="fast", + help=( + "Indexing mode for every compared run. Use full or moderate when measuring " + "SIMILAR_TO or SEMANTICALLY_RELATED quality; fast intentionally skips both." + ), + ) + parser.add_argument( + "--rank-refresh", + choices=( + RANK_REFRESH_CANDIDATE_DEFAULT, + *RANK_REFRESH_POLICIES, + ), + default=DEFAULT_RANK_REFRESH, + help=( + "Preserve the candidate's compiled/configured default unless an explicit policy " + "is selected. This is independent of --config-profile." + ), + ) + parser.add_argument( + "--config-profile", + choices=tuple(CONFIG_PROFILES), + default=CONFIG_PROFILE_DEFAULT, + help=( + "Named, auditable configuration profile. The default " + "automatic_dependency_source_indexing_disabled sets auto_index_deps=false; " + "automatic_dependency_source_indexing_enabled sets it true; " + "candidate_native_configuration applies no override for binaries that do not " + "support this setting. minimal_indexing disables automatic dependency-source " + "indexing plus every optional graph/rank pass. Repeated --config KEY=VALUE " + "arguments take priority over the selected profile." + ), + ) + parser.add_argument( + "--config", + action="append", + default=[], + metavar="KEY=VALUE", + help="Additional config override; repeat to set multiple keys. Applied after built-in settings.", + ) + parser.add_argument( + "--product-env", + action="append", + default=[], + metavar="CBM_KEY=VALUE", + help=( + "Explicit candidate process environment; repeat for controlled worker or " + "memory sweeps. Keys must start with CBM_. Cache, profiling, auto-index, " + "and run-context variables remain owned by the benchmark harness." + ), + ) + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS) + parser.add_argument("--keep-work-root", action="store_true") + parser.add_argument("--include-logs", action="store_true") + parser.add_argument( + "--mcp-surface-parity", + action="store_true", + help=( + "Measure classic startup, streamlined pre-reveal, and streamlined post-reveal " + "tool discovery without indexing a repository." + ), + ) + parser.add_argument( + "--list-projects-scaling", + action="store_true", + help=( + "Measure list_projects alone against isolated cloned project databases using " + "a fresh MCP server per configured count." + ), + ) + parser.add_argument( + "--list-project-counts", + default=DEFAULT_LIST_PROJECT_COUNTS, + help="Strictly increasing positive project counts for --list-projects-scaling.", + ) + parser.add_argument( + "--list-project-fixture-max-mb", + type=int, + default=DEFAULT_LIST_PROJECT_FIXTURE_MAX_MB, + help="Hard disk cap for cloned list-project fixtures before any clone is created.", + ) + parser.add_argument( + "--search-projection", + action="store_true", + help=( + "Compare compact default/true, selected fields, and compact=false JSON projection " + "for identical ranked results." + ), + ) + parser.add_argument( + "--search-projection-results", + type=int, + default=30, + help="Bounded matching result count for --search-projection.", + ) + parser.add_argument( + "--capability-quality", + choices=CAPABILITY_QUALITY_CASES, + default="", + help=( + "Run one isolated, deterministic capability-quality fixture. rank measures whether " + "structural ranking lifts the central result above lexical decoys; dependencies " + "measures local npm API retrieval with source/package/read-only provenance; similarity " + "scores SIMILAR_TO structural-clone pairs and semantic_edges scores " + "SEMANTICALLY_RELATED control-flow variants against explicit hard negatives; " + "git_history measures FILE_CHANGES_WITH retrieval for a deterministic four-commit " + "co-change history; http_links measures HTTP_CALLS retrieval for a client-to-route " + "fixture." + ), + ) + parser.add_argument( + "--quality-background-repo", + default="", + help=( + "Optional Git repository whose tracked files at an exact revision form the realistic " + "background for similarity or semantic_edges canaries. Dirty and untracked source " + "state is excluded." + ), + ) + parser.add_argument( + "--quality-background-revision", + default="", + help="Commit-ish copied by git archive for --quality-background-repo; experiments should use a full hash.", + ) + parser.add_argument( + "--matrix", + action="store_true", + help="Run the affected-frontier scenario matrix.", + ) + parser.add_argument( + "--self-dogfood", + action="store_true", + help="Run isolated edit-loop scenarios against a detached worktree of --repo-root.", + ) + parser.add_argument( + "--repo-revision", + default="HEAD", + help=( + "Exact commit used for --self-dogfood detached worktrees. Experiments should pass " + "a full hash so mutable source HEAD cannot change the measured corpus." + ), + ) + parser.add_argument( + "--matrix-scenarios", + default=MATRIX_SCENARIOS_DEFAULT, + help="Comma-separated matrix scenarios to run.", + ) + parser.add_argument( + "--frontier-files", + type=int, + default=DEFAULT_FRONTIER_FILES, + help=( + "Number of inbound-dependent source files created by each *_inbound_frontier " + "matrix scenario. The changed definition file is additional." + ), + ) + parser.add_argument( + "--fastapi-repo", + default="", + help=( + "Existing FastAPI checkout for matrix scenario fastapi_insert_probe. " + "Defaults also check CBM_FASTAPI_REPO and common local cache/source paths." + ), + ) + parser.add_argument( + "--fastapi-url", + default=DEFAULT_FASTAPI_URL, + help="Clone URL used only with --clone-missing-real-repos.", + ) + parser.add_argument( + "--clone-missing-real-repos", + action="store_true", + help="Clone missing real benchmark repos into the isolated work root.", + ) + parser.add_argument( + "--self-dogfood-scenarios", + default=SELF_DOGFOOD_SCENARIOS_DEFAULT, + help="Comma-separated real-repo edit-loop scenarios to run.", + ) + parser.add_argument( + "--transport", + choices=("cli", "mcp"), + default="cli", + help="Measure cold CLI subprocess calls or persistent MCP tool-call latency.", + ) + parser.add_argument( + "--overhead-probes", + type=int, + default=DEFAULT_OVERHEAD_PROBES, + help=( + "Run N cheap tool-call probes before indexing to estimate invocation overhead; " + "0 preserves the historical gate behavior." + ), + ) + parser.add_argument( + "--overhead-tool", + default=DEFAULT_OVERHEAD_TOOL, + help="Existing MCP tool used by --overhead-probes.", + ) + parser.add_argument( + "--indexed-query-probes", + type=int, + default=DEFAULT_INDEXED_QUERY_PROBES, + help=( + "Run N project-scoped tool calls after initial indexing to measure the " + "file-backed query path; 0 preserves the historical gate behavior." + ), + ) + parser.add_argument( + "--indexed-query-tool", + default=DEFAULT_INDEXED_QUERY_TOOL, + help="Existing project-aware MCP tool used by --indexed-query-probes.", + ) + args = parser.parse_args() + if args.build_metadata_json: + try: + args.build_metadata = json.loads(args.build_metadata_json) + except json.JSONDecodeError as exc: + parser.error(f"--build-metadata-json must contain valid JSON: {exc}") + if not isinstance(args.build_metadata, dict): + parser.error("--build-metadata-json must contain a JSON object") + else: + args.build_metadata = {} + args.config_overrides = resolve_config_overrides(args.config_profile, args.config) + args.product_environment = parse_product_environment(args.product_env) + return args + + +def resolve_binary_path(binary_arg: str) -> Path: + binary = Path(binary_arg).expanduser() + if binary.is_absolute(): + return binary.resolve() + cwd_candidate = (Path.cwd() / binary).resolve() + if cwd_candidate.is_file(): + return cwd_candidate + script_candidate = (Path(__file__).resolve().parents[1] / binary).resolve() + return script_candidate + + +def main() -> int: + args = parse_args() + if args.describe_terms: + path = ( + BENCHMARK_TERMINOLOGY_PATH + if args.describe_terms == "json" + else BENCHMARK_TERMINOLOGY_MARKDOWN_PATH + ) + try: + sys.stdout.write(path.read_text(encoding="utf-8")) + except OSError as exc: + print(f"error: cannot read benchmark terminology: {exc}", file=sys.stderr) + return 2 + return 0 + if args.import_report: + if not args.facts_dir: + print("error: --import-report requires --facts-dir", file=sys.stderr) + return 2 + source = Path(args.import_report).expanduser() + try: + report = json.loads(source.read_text(encoding="utf-8")) + if not isinstance(report, dict): + raise ValueError("legacy benchmark report must be a JSON object") + embedded_context = report.get("benchmark_run_context") + context = embedded_context if isinstance(embedded_context, dict) else {} + facts = normalize_benchmark_report(report, context, imported_report=True) + facts["artifacts"].append( + { + "run_id": facts["runs"][0]["run_id"], + "artifact_id": hashlib.sha256( + canonical_json_bytes( + { + "path": str(source.resolve()), + "sha256": file_sha256(source), + } + ) + ).hexdigest()[:24], + "artifact_type": "legacy_source_report", + "path": str(source.resolve()), + "sha256": file_sha256(source), + "size_bytes": source.stat().st_size, + "schema_version": report.get( + "schema_version", + unknown_fact("legacy_report_schema_not_recorded"), + ), + "cleanup_status": "retained", + } + ) + manifest = write_benchmark_fact_tables( + facts, Path(args.facts_dir).expanduser() + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"error: cannot import benchmark report: {exc}", file=sys.stderr) + return 2 + print(json.dumps(manifest, indent=2, sort_keys=True)) + return 0 + binary = resolve_binary_path(args.binary) + if not binary.is_file(): + print(f"error: binary not found: {binary}", file=sys.stderr) + return 2 + if args.list_projects_scaling: + _, list_exit_code = run_list_projects_scaling(args, binary) + return list_exit_code + if args.search_projection: + _, projection_exit_code = run_search_projection(args, binary) + return projection_exit_code + if args.mcp_surface_parity: + _, surface_exit_code = run_mcp_surface_parity(args, binary) + return surface_exit_code + if args.capability_quality: + _, quality_exit_code = run_capability_quality(args, binary) + return quality_exit_code + if args.matrix: + _, matrix_exit_code = run_matrix(args, binary) + return matrix_exit_code + if args.self_dogfood: + _, self_dogfood_exit_code = run_self_dogfood(args, binary) + return self_dogfood_exit_code + + auto_root = not bool(args.work_root) + work_root = ( + Path(args.work_root).expanduser() + if args.work_root + else Path(tempfile.mkdtemp(prefix="cbm-incr-speed-")) + ) + work_root.mkdir(parents=True, exist_ok=True) + repo_dir = work_root / "repo" + cache_dir = work_root / "cache" + repo_dir.mkdir(parents=True, exist_ok=True) + cache_dir.mkdir(parents=True, exist_ok=True) + + report: dict[str, Any] = { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "binary": str(binary), + "binary_metadata": binary_metadata(binary), + "work_root": str(work_root), + "parameters": { + "files": args.files, + "functions_per_file": args.functions_per_file, + "changed_files": args.changed_files, + "min_speedup": args.min_speedup, + "rank_refresh": args.rank_refresh, + "rank_refresh_override_applied": ( + args.rank_refresh != RANK_REFRESH_CANDIDATE_DEFAULT + ), + "index_mode": args.index_mode, + "capability_applicability": index_mode_capability_applicability( + args.index_mode + ), + "config_profile": args.config_profile, + "config_overrides": args.config_overrides, + "configuration_environment": benchmark_environment_policy( + args.product_environment + ), + "timeout": args.timeout, + "transport": args.transport, + "overhead_probes": args.overhead_probes, + "overhead_tool": args.overhead_tool, + "indexed_query_probes": args.indexed_query_probes, + "indexed_query_tool": args.indexed_query_tool, + }, + "cleanup": { + "requested": auto_root and not args.keep_work_root, + "removed": False, + }, + } + + exit_code = 1 + try: + create_repo(repo_dir, args.files, args.functions_per_file) + env = build_env(cache_dir, args.product_environment) + run_config_set(binary, env, "incremental_reindex", "always", args.timeout) + apply_rank_refresh_override(binary, env, args.rank_refresh, args.timeout) + apply_config_overrides(binary, env, args.config_overrides, args.timeout) + + if args.transport == "mcp": + with McpClient(binary, env, args.timeout) as client: + overhead_probe = measure_mcp_overhead_probes( + client, args.overhead_tool, args.overhead_probes, args.include_logs + ) + initial = run_index_mcp( + client, repo_dir, args.include_logs, args.index_mode + ) + indexed_query_probe = measure_mcp_overhead_probes( + client, + args.indexed_query_tool, + args.indexed_query_probes, + args.include_logs, + {"project": str(repo_dir)}, + ) + changed_paths = modify_existing_files( + repo_dir, args.changed_files, args.functions_per_file + ) + incremental = run_index_mcp( + client, repo_dir, args.include_logs, args.index_mode + ) + removed_dbs = remove_project_dbs(cache_dir) + with McpClient(binary, env, args.timeout) as client: + full_rebuild = run_index_mcp( + client, repo_dir, args.include_logs, args.index_mode + ) + else: + overhead_probe = measure_cli_overhead_probes( + binary, + env, + args.overhead_tool, + args.overhead_probes, + args.timeout, + args.include_logs, + ) + initial = run_index( + binary, env, repo_dir, args.timeout, args.include_logs, args.index_mode + ) + indexed_query_probe = measure_cli_overhead_probes( + binary, + env, + args.indexed_query_tool, + args.indexed_query_probes, + args.timeout, + args.include_logs, + {"project": str(repo_dir)}, + ) + changed_paths = modify_existing_files( + repo_dir, args.changed_files, args.functions_per_file + ) + incremental = run_index( + binary, env, repo_dir, args.timeout, args.include_logs, args.index_mode + ) + removed_dbs = remove_project_dbs(cache_dir) + full_rebuild = run_index( + binary, env, repo_dir, args.timeout, args.include_logs, args.index_mode + ) + + incr_ms = max(1, int(incremental["elapsed_ms"])) + full_ms = max(1, int(full_rebuild["elapsed_ms"])) + speedup = full_ms / incr_ms + incremental_markers = incremental["markers"] + explicit_incremental_route = is_incremental_publish_kind( + str(incremental.get("publish_kind") or "") + ) + defer_marker = bool(incremental_markers["pagerank_defer"]) + passed = speedup >= args.min_speedup and explicit_incremental_route + + report.update( + { + "changed_paths": changed_paths, + "removed_project_dbs": removed_dbs, + "measurements": { + "overhead_probe": overhead_probe, + "indexed_query_probe": indexed_query_probe, + "initial_fast_full": initial, + "incremental_exact": incremental, + "incremental": incremental, + "fresh_fast_full_after_change": full_rebuild, + }, + "derived": { + "speedup_full_rebuild_over_incremental": speedup, + "exact_incremental_marker_seen": explicit_incremental_route, + "explicit_incremental_route_seen": explicit_incremental_route, + "rank_defer_marker_seen": defer_marker, + "passed": passed, + }, + } + ) + exit_code = 0 if passed else 1 + except Exception as exc: + record_report_error(report, exc) + exit_code = 1 + finally: + if auto_root and not args.keep_work_root: + shutil.rmtree(work_root, ignore_errors=True) + report["cleanup"]["removed"] = not work_root.exists() + emit_report(report, args) + + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/run_container_experiment.py b/benchmarks/run_container_experiment.py new file mode 100644 index 000000000..f117afd8d --- /dev/null +++ b/benchmarks/run_container_experiment.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 +"""Run the existing benchmark matrix in a native, resource-bounded Docker cohort. + +The coordinator owns Docker isolation and artifact transfer only. Candidate +resolution, measurements, correctness gates, immutable plans, and reports remain +implemented by run_experiments.py and run_benchmark.py. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import platform +import re +import shutil +import subprocess +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DOCKERFILE = ROOT / "test-infrastructure" / "Dockerfile" +DEFAULT_BUILD_ENVIRONMENT = { + "CC": "clang-18", + "CXX": "clang++-18", +} +OWNED_RUNNER_FLAGS = frozenset( + { + "--candidate-root", + "--candidate-search-root", + "--build-jobs", + "--experiment-root", + "--matrix-spec", + "--plan", + "--product-env", + "--quick", + "--full", + } +) +MEMORY_LIMIT_PATTERN = re.compile(r"^[1-9][0-9]*(?:b|k|m|g|t)$", re.IGNORECASE) +CONTAINER_SCRIPT = r""" +set -euo pipefail +source_revision=$1 +bundle_name=$2 +source_key=$3 +shift 3 +export HOME=/benchmark/home +mkdir -p "$HOME" /benchmark/sources +repository=/benchmark/sources/$source_key +if [ ! -d "$repository/.git" ]; then + git clone --quiet "/benchmark/$bundle_name" "$repository" +fi +git -C "$repository" checkout --quiet --detach "$source_revision" +if [ -n "$(git -C "$repository" status --porcelain --untracked-files=no)" ]; then + echo "benchmark source clone has tracked changes: $repository" >&2 + exit 65 +fi +cd "$repository" +exec python3 benchmarks/run_experiments.py "$@" +""".strip() + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_container_manifest( + experiment_root: Path, + source_revision: str, + bundle_sha256: str, + manifest: dict[str, Any], +) -> Path: + """Write an immutable, content-addressed environment record.""" + payload = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") + manifest_sha = hashlib.sha256(payload).hexdigest() + path = ( + experiment_root + / "manifests" + / ( + f"container-environment-{source_revision[:12]}-{bundle_sha256[:12]}-" + f"{manifest_sha[:12]}.json" + ) + ) + if path.exists() and path.read_bytes() != payload: + raise RuntimeError(f"manifest hash collision at {path}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + return path + + +def failure_log_export_root(experiment_root: Path, source_revision: str) -> Path: + """Return the commit-keyed destination for failed candidate build logs.""" + return experiment_root / "container-failures" / source_revision[:12] / "build-logs" + + +def repository_snapshot_sha256( + source_revision: str, bundle_heads: list[dict[str, str]] +) -> str: + """Hash Git commit/ref content independently of bundle pack bytes.""" + identity = { + "source_revision": source_revision, + "bundle_heads": sorted( + ( + {"ref": head["ref"], "revision": head["revision"]} + for head in bundle_heads + ), + key=lambda head: (head["ref"], head["revision"]), + ), + } + payload = json.dumps(identity, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) + return hashlib.sha256(payload).hexdigest() + + +def container_run_key( + *, + source_revision: str, + repository_snapshot_sha256: str, + matrix_spec_sha256: str | None, + resources: dict[str, Any], + runner_arguments: list[str], +) -> str: + """Identify one resumable measurement cohort inside a named history.""" + identity = { + "source_revision": source_revision, + "repository_snapshot_sha256": repository_snapshot_sha256, + "matrix_spec_sha256": matrix_spec_sha256, + "resources": resources, + # Audit-only changes execution, not the measured plan or environment. + "runner_arguments": [ + argument for argument in runner_arguments if argument != "--audit-only" + ], + } + payload = json.dumps(identity, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) + return hashlib.sha256(payload).hexdigest()[:24] + + +def native_linux_platform(machine: str) -> str: + normalized = machine.strip().lower() + if normalized in {"arm64", "aarch64"}: + return "linux/arm64" + if normalized in {"amd64", "x86_64"}: + return "linux/amd64" + raise ValueError( + f"unsupported host architecture {machine!r}; use a native arm64 or amd64 " + "host for performance measurements" + ) + + +def validate_resources(cpus: float, memory: str, workers: int) -> dict[str, Any]: + if not math.isfinite(cpus) or cpus <= 0: + raise ValueError("benchmark CPUs must be a finite value greater than zero") + if not MEMORY_LIMIT_PATTERN.fullmatch(memory): + raise ValueError( + "benchmark memory must be an explicit Docker limit such as 8g or 16384m" + ) + if workers <= 0: + raise ValueError("benchmark workers must be greater than zero") + if workers > cpus: + raise ValueError( + f"benchmark workers ({workers}) cannot exceed the CPU budget ({cpus:g})" + ) + return {"cpus": cpus, "memory": memory.lower(), "workers": workers} + + +def resolve_build_jobs(cpus: float, requested: int | None) -> int: + """Use the container's complete declared CPU capacity unless overridden.""" + if requested is not None: + if requested <= 0: + raise ValueError("benchmark build jobs must be greater than zero") + return requested + return max(1, math.ceil(cpus)) + + +def validate_forwarded_arguments(arguments: list[str]) -> list[str]: + values = list(arguments) + if values[:1] == ["--"]: + values.pop(0) + conflicts = sorted( + item for item in values if item.partition("=")[0] in OWNED_RUNNER_FLAGS + ) + if conflicts: + raise ValueError( + "runner arguments cannot replace coordinator-owned flags: " + + ", ".join(conflicts) + ) + return values + + +def volume_mount(name: str, destination: str) -> str: + return f"type=volume,src={name},dst={destination}" + + +def bundle_revision_arguments() -> list[str]: + """Include benchmarkable refs without copying stash or recovery namespaces.""" + return ["HEAD", "--branches", "--tags", "--remotes"] + + +def materialize_container_matrix_spec( + source: Path, destination: Path, workers: int +) -> str: + try: + document = json.loads(source.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ValueError(f"matrix spec is not valid JSON: {source}") from error + if not isinstance(document, dict): + raise ValueError("matrix spec must be a JSON object") + product_environment = document.get("product_environment", {}) + if not isinstance(product_environment, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in product_environment.items() + ): + raise ValueError("matrix spec product_environment must be string-to-string") + declared_workers = product_environment.get("CBM_WORKERS") + expected_workers = str(workers) + if declared_workers not in {None, expected_workers}: + raise ValueError( + "matrix spec CBM_WORKERS conflicts with the container resource budget: " + f"spec={declared_workers} coordinator={expected_workers}" + ) + document["product_environment"] = { + **product_environment, + "CBM_WORKERS": expected_workers, + } + build_environment = document.get("build_environment", {}) + if not isinstance(build_environment, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in build_environment.items() + ): + raise ValueError("matrix spec build_environment must be string-to-string") + declared_compilers = { + key for key in DEFAULT_BUILD_ENVIRONMENT if key in build_environment + } + if declared_compilers and declared_compilers != set(DEFAULT_BUILD_ENVIRONMENT): + raise ValueError("container matrix specs must declare CC and CXX together") + document["build_environment"] = { + **DEFAULT_BUILD_ENVIRONMENT, + **build_environment, + } + payload = (json.dumps(document, indent=2, sort_keys=True) + "\n").encode("utf-8") + destination.write_bytes(payload) + return hashlib.sha256(payload).hexdigest() + + +def build_measured_command( + *, + docker: str, + image: str, + platform_name: str, + resources: dict[str, Any], + container_name: str, + work_volume: str, + results_volume: str, + source_revision: str, + bundle_name: str, + repository_snapshot_sha256: str, + runner_arguments: list[str], + experiment_root: str, + uid: int | None, + gid: int | None, +) -> list[str]: + command = [ + docker, + "run", + "--rm", + # Candidate CLIs can detach supervised workers. Docker's init forwards + # stop signals and reaps each exited descendant in O(children) total + # work, preventing earlier cells from polluting later process tables. + "--init", + "--name", + container_name, + "--platform", + platform_name, + "--cpus", + f"{resources['cpus']:g}", + "--memory", + resources["memory"], + "--mount", + volume_mount(work_volume, "/benchmark"), + "--mount", + volume_mount(results_volume, "/results"), + "--entrypoint", + "/bin/bash", + ] + if uid is not None and gid is not None: + command.extend(("--user", f"{uid}:{gid}")) + for key, value in DEFAULT_BUILD_ENVIRONMENT.items(): + command.extend(("--env", f"{key}={value}")) + source_key = repository_snapshot_sha256[:20] + command.extend( + ( + image, + "-c", + CONTAINER_SCRIPT, + "cbm-benchmark-container", + source_revision, + bundle_name, + source_key, + *runner_arguments, + "--experiment-root", + experiment_root, + ) + ) + return command + + +def merge_exported_tree(source: Path, destination: Path) -> None: + """Merge immutable exported artifacts without replacing different bytes.""" + destination.mkdir(parents=True, exist_ok=True) + for source_path in sorted(source.rglob("*")): + relative = source_path.relative_to(source) + destination_path = destination / relative + if source_path.is_symlink(): + raise RuntimeError(f"export contains an unsupported symlink: {source_path}") + if source_path.is_dir(): + destination_path.mkdir(parents=True, exist_ok=True) + continue + if destination_path.exists(): + if not destination_path.is_file(): + raise RuntimeError( + f"export destination is not a file: {destination_path}" + ) + if file_sha256(source_path) != file_sha256(destination_path): + raise RuntimeError( + f"export destination contains different bytes: {destination_path}" + ) + continue + destination_path.parent.mkdir(parents=True, exist_ok=True) + temporary = destination_path.with_name( + f".{destination_path.name}.container-export-{os.getpid()}" + ) + shutil.copy2(source_path, temporary) + os.replace(temporary, destination_path) + + +def run_command( + command: list[str], + *, + cwd: Path | None = None, + capture: bool = False, +) -> subprocess.CompletedProcess[str]: + process = subprocess.run( + command, + cwd=cwd, + text=True, + capture_output=capture, + check=False, + ) + if process.returncode != 0: + detail = "" + if capture: + detail = (process.stderr or process.stdout).strip() + suffix = f": {detail}" if detail else "" + raise RuntimeError( + f"command failed with exit {process.returncode}: " + f"{' '.join(command[:4])}{suffix}" + ) + return process + + +def docker_json(docker: str, arguments: list[str]) -> dict[str, Any]: + process = run_command([docker, *arguments], capture=True) + try: + value = json.loads(process.stdout) + except json.JSONDecodeError as error: + raise RuntimeError( + f"Docker returned invalid JSON for {' '.join(arguments)}" + ) from error + if not isinstance(value, dict): + raise RuntimeError(f"Docker returned non-object JSON for {' '.join(arguments)}") + return value + + +def remove_container(docker: str, name: str) -> None: + subprocess.run( + [docker, "rm", "--force", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + + +def ensure_volume(docker: str, name: str, role: str) -> None: + inspect = subprocess.run( + [docker, "volume", "inspect", name, "--format", "{{json .Labels}}"], + text=True, + capture_output=True, + check=False, + ) + expected = { + "com.codebase-memory-mcp.benchmark": "true", + "com.codebase-memory-mcp.role": role, + } + if inspect.returncode == 0: + labels = json.loads(inspect.stdout) + if labels != expected: + raise RuntimeError( + f"Docker volume {name} exists without the expected benchmark " + f"ownership labels; choose a different experiment root" + ) + return + run_command( + [ + docker, + "volume", + "create", + "--label", + "com.codebase-memory-mcp.benchmark=true", + "--label", + f"com.codebase-memory-mcp.role={role}", + name, + ] + ) + + +def copy_to_volume( + docker: str, + image: str, + volume: str, + volume_destination: str, + source: Path, + container_name: str, + *, + copy_destination: str | None = None, +) -> None: + destination = copy_destination or volume_destination + copy_source = f"{source}{os.sep}." if source.is_dir() else str(source) + remove_container(docker, container_name) + try: + run_command( + [ + docker, + "create", + "--name", + container_name, + "--mount", + volume_mount(volume, volume_destination), + "--entrypoint", + "/bin/mkdir", + image, + "-p", + destination, + ] + ) + run_command([docker, "start", "--attach", container_name]) + run_command([docker, "cp", copy_source, f"{container_name}:{destination}"]) + finally: + remove_container(docker, container_name) + + +def export_results( + docker: str, + image: str, + results_volume: str, + destination: Path, + container_name: str, +) -> None: + remove_container(docker, container_name) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".cbm-container-export-", dir=destination.parent + ) as tmpdir: + staging = Path(tmpdir) + try: + run_command( + [ + docker, + "create", + "--name", + container_name, + "--mount", + volume_mount(results_volume, "/results"), + "--entrypoint", + "/bin/true", + image, + ] + ) + run_command([docker, "cp", f"{container_name}:/results/.", str(staging)]) + finally: + remove_container(docker, container_name) + merge_exported_tree(staging, destination) + + +def export_volume_subtree( + docker: str, + image: str, + volume: str, + volume_destination: str, + subtree: str, + destination: Path, + container_name: str, +) -> None: + """Export one named-volume subtree through the immutable history merge.""" + remove_container(docker, container_name) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".cbm-container-subtree-export-", dir=destination.parent + ) as tmpdir: + staging = Path(tmpdir) + try: + run_command( + [ + docker, + "create", + "--name", + container_name, + "--mount", + volume_mount(volume, volume_destination), + "--entrypoint", + "/bin/true", + image, + ] + ) + run_command([docker, "cp", f"{container_name}:{subtree}/.", str(staging)]) + finally: + remove_container(docker, container_name) + merge_exported_tree(staging, destination) + + +def parse_bundle_heads(bundle: Path) -> list[dict[str, str]]: + process = run_command( + ["git", "bundle", "list-heads", str(bundle)], cwd=ROOT, capture=True + ) + heads: list[dict[str, str]] = [] + for line in process.stdout.splitlines(): + revision, separator, ref = line.partition(" ") + if separator and len(revision) == 40: + heads.append({"revision": revision, "ref": ref}) + return heads + + +def git_output(arguments: list[str]) -> str: + return run_command(["git", *arguments], cwd=ROOT, capture=True).stdout.strip() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group() + source.add_argument("--matrix-spec", type=Path) + source.add_argument("--quick", action="store_true") + source.add_argument("--full", action="store_true") + parser.add_argument("--experiment-root", type=Path, required=True) + parser.add_argument("--cpus", type=float, required=True) + parser.add_argument("--memory", required=True) + parser.add_argument("--workers", type=int, required=True) + parser.add_argument( + "--build-jobs", + type=int, + help=( + "candidate build parallelism; defaults to the complete --cpus budget " + "rounded up" + ), + ) + parser.add_argument("--image") + parser.add_argument("--docker", default="docker") + parser.add_argument( + "runner_arguments", + nargs=argparse.REMAINDER, + help="Additional run_experiments.py arguments after --.", + ) + return parser + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + parser = build_parser() + args = parser.parse_args(argv) + if not args.quick and not args.full and args.matrix_spec is None: + args.quick = True + try: + args.resources = validate_resources(args.cpus, args.memory, args.workers) + args.build_jobs = resolve_build_jobs(args.cpus, args.build_jobs) + args.runner_arguments = validate_forwarded_arguments(args.runner_arguments) + args.platform = native_linux_platform(platform.machine()) + except ValueError as error: + parser.error(str(error)) + args.experiment_root = args.experiment_root.expanduser().resolve() + if args.matrix_spec is not None: + args.matrix_spec = args.matrix_spec.expanduser().resolve() + if not args.matrix_spec.is_file(): + parser.error(f"matrix spec does not exist: {args.matrix_spec}") + return args + + +def main(argv: list[str] | None = None) -> int: + args = parse_arguments(argv) + tracked = git_output(["status", "--porcelain", "--untracked-files=no"]) + if tracked: + raise RuntimeError( + "benchmark source worktree has tracked changes; commit or preserve them " + "before creating the immutable container input" + ) + source_revision = git_output(["rev-parse", "HEAD"]) + docker_info = docker_json(args.docker, ["info", "--format", "{{json .}}"]) + server_platform = native_linux_platform(str(docker_info.get("Architecture", ""))) + if server_platform != args.platform: + raise RuntimeError( + f"Docker server architecture {server_platform} does not match native " + f"host platform {args.platform}; emulation is not valid for performance" + ) + + dockerfile_sha = file_sha256(DOCKERFILE) + image = args.image or ( + f"cbm-benchmark-runtime:{dockerfile_sha[:12]}-{args.platform.rsplit('/', 1)[1]}" + ) + if args.image is None: + with tempfile.TemporaryDirectory( + prefix="cbm-benchmark-empty-build-context-" + ) as empty_context: + run_command( + [ + args.docker, + "build", + "--platform", + args.platform, + "--file", + str(DOCKERFILE), + "--tag", + image, + empty_context, + ] + ) + image_metadata = docker_json( + args.docker, + [ + "image", + "inspect", + image, + "--format", + "{{json .}}", + ], + ) + if ( + f"{image_metadata.get('Os')}/{image_metadata.get('Architecture')}" + != args.platform + ): + raise RuntimeError( + f"image platform {image_metadata.get('Os')}/" + f"{image_metadata.get('Architecture')} does not match {args.platform}" + ) + + history_key = hashlib.sha256(str(args.experiment_root).encode("utf-8")).hexdigest()[ + :16 + ] + work_volume = f"cbm-benchmark-work-{history_key}" + results_volume = f"cbm-benchmark-results-{history_key}" + ensure_volume(args.docker, work_volume, "work") + ensure_volume(args.docker, results_volume, "results") + + name_prefix = f"cbm-benchmark-{history_key}-{os.getpid()}" + seed_name = f"{name_prefix}-seed" + measured_name = f"{name_prefix}-measured" + export_name = f"{name_prefix}-export" + try: + with tempfile.TemporaryDirectory(prefix="cbm-benchmark-input-") as tmpdir: + input_root = Path(tmpdir) + bundle = input_root / "repository.bundle" + run_command( + [ + "git", + "bundle", + "create", + str(bundle), + *bundle_revision_arguments(), + ], + cwd=ROOT, + ) + run_command( + ["git", "bundle", "verify", str(bundle)], cwd=ROOT, capture=True + ) + bundle_sha = file_sha256(bundle) + bundle_name = f"repository-{bundle_sha}.bundle" + copied_bundle = input_root / bundle_name + bundle.replace(copied_bundle) + bundle_heads = parse_bundle_heads(copied_bundle) + repository_snapshot = repository_snapshot_sha256( + source_revision, bundle_heads + ) + source_key = repository_snapshot[:20] + copy_to_volume( + args.docker, + image, + work_volume, + "/benchmark", + copied_bundle, + seed_name, + ) + + runner_arguments: list[str] + matrix_sha: str | None = None + effective_matrix_sha: str | None = None + if args.matrix_spec is not None: + matrix_sha = file_sha256(args.matrix_spec) + provisional_matrix = input_root / "matrix-effective.json" + effective_matrix_sha = materialize_container_matrix_spec( + args.matrix_spec, + provisional_matrix, + args.resources["workers"], + ) + matrix_name = f"matrix-{effective_matrix_sha}.json" + copied_matrix = input_root / matrix_name + provisional_matrix.replace(copied_matrix) + copy_to_volume( + args.docker, + image, + work_volume, + "/benchmark", + copied_matrix, + seed_name, + ) + runner_arguments = ["--matrix-spec", f"/benchmark/{matrix_name}"] + elif args.full: + runner_arguments = [ + "--full", + "--product-env", + f"CBM_WORKERS={args.resources['workers']}", + ] + else: + runner_arguments = [ + "--quick", + "--product-env", + f"CBM_WORKERS={args.resources['workers']}", + ] + runner_arguments.extend(("--build-jobs", str(args.build_jobs))) + runner_arguments.extend(args.runner_arguments) + run_key = container_run_key( + source_revision=source_revision, + repository_snapshot_sha256=repository_snapshot, + matrix_spec_sha256=effective_matrix_sha, + resources=args.resources, + runner_arguments=runner_arguments, + ) + container_experiment_root = f"/results/runsets/{run_key}" + + manifest = { + "schema_version": 1, + "recorded_at_utc": utc_now(), + "source_revision": source_revision, + "source_tree": git_output(["rev-parse", "HEAD^{tree}"]), + "bundle_sha256": bundle_sha, + "bundle_heads": bundle_heads, + "repository_snapshot_sha256": repository_snapshot, + "matrix_spec_sha256": matrix_sha, + "effective_matrix_spec_sha256": effective_matrix_sha, + "image": image, + "image_id": image_metadata.get("Id"), + "image_repo_digests": image_metadata.get("RepoDigests") or [], + "docker_server": { + key: docker_info.get(key) + for key in ( + "Architecture", + "Driver", + "MemTotal", + "NCPU", + "OperatingSystem", + "OSType", + "ServerVersion", + ) + }, + "platform": args.platform, + "resources": args.resources, + "build_jobs": args.build_jobs, + "default_build_environment": DEFAULT_BUILD_ENVIRONMENT, + "work_volume": work_volume, + "results_volume": results_volume, + "volumes_retained_for_resume": True, + "runner_arguments": runner_arguments, + "run_key": run_key, + "container_experiment_root": container_experiment_root, + "container_repository": f"/benchmark/sources/{source_key}", + } + manifest_path = write_container_manifest( + input_root, + source_revision, + bundle_sha, + manifest, + ) + copy_to_volume( + args.docker, + image, + results_volume, + "/results", + manifest_path.parent, + seed_name, + copy_destination="/results/manifests", + ) + + uid = os.getuid() if hasattr(os, "getuid") else None + gid = os.getgid() if hasattr(os, "getgid") else None + if uid is not None and gid is not None: + run_command( + [ + args.docker, + "run", + "--rm", + "--mount", + volume_mount(work_volume, "/benchmark"), + "--mount", + volume_mount(results_volume, "/results"), + "--entrypoint", + "/bin/chown", + image, + "-R", + f"{uid}:{gid}", + "/benchmark", + "/results", + ] + ) + measured = build_measured_command( + docker=args.docker, + image=image, + platform_name=args.platform, + resources=args.resources, + container_name=measured_name, + work_volume=work_volume, + results_volume=results_volume, + source_revision=source_revision, + bundle_name=bundle_name, + repository_snapshot_sha256=repository_snapshot, + runner_arguments=runner_arguments, + experiment_root=container_experiment_root, + uid=uid, + gid=gid, + ) + measured_process = subprocess.run(measured, text=True, check=False) + export_results( + args.docker, + image, + results_volume, + args.experiment_root, + export_name, + ) + if measured_process.returncode != 0: + failure_logs = failure_log_export_root( + args.experiment_root, source_revision + ) + try: + export_volume_subtree( + args.docker, + image, + work_volume, + "/benchmark", + ( + f"/benchmark/sources/{source_key}/.worktrees/" + "benchmark-candidates/build-logs" + ), + failure_logs, + export_name, + ) + failure_log_detail = ( + f"; candidate build logs exported to {failure_logs}" + ) + except RuntimeError as log_error: + failure_log_detail = ( + "; candidate build logs could not be exported automatically " + f"({log_error}); inspect {work_volume} at " + f"/benchmark/sources/{source_key}/.worktrees/" + "benchmark-candidates/build-logs" + ) + raise RuntimeError( + "container benchmark failed with exit " + f"{measured_process.returncode}; partial immutable results were " + f"exported to {args.experiment_root}{failure_log_detail}" + ) + except Exception as error: + raise RuntimeError( + f"{error}; benchmark volumes retained for inspection or resume: " + f"{work_volume}, {results_volume}" + ) from error + finally: + for name in (seed_name, measured_name, export_name): + remove_container(args.docker, name) + + print( + json.dumps( + { + "status": "complete", + "experiment_root": str(args.experiment_root), + "work_volume": work_volume, + "results_volume": results_volume, + "volumes_retained_for_resume": True, + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/run_experiments.py b/benchmarks/run_experiments.py new file mode 100755 index 000000000..8485ef513 --- /dev/null +++ b/benchmarks/run_experiments.py @@ -0,0 +1,2856 @@ +#!/usr/bin/env python3 +"""Run an immutable, resumable benchmark experiment plan and retain an auditable disk trail. + +This is the canonical multi-run entry point. Retained flag spellings, persisted keys, +and `.worktrees/benchmark-campaign/` directories remain readable; new interfaces and +records use "experiment" consistently. +""" + +from __future__ import annotations + +import argparse +import copy +from contextlib import suppress +import hashlib +import json +import math +import os +import platform +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + + +CONFIG_SPELLING_SPEC_PATH = Path(__file__).with_name("config-spellings-v1.json") +with CONFIG_SPELLING_SPEC_PATH.open(encoding="utf-8") as stream: + CONFIG_SPELLING_SPEC = json.load(stream) +if CONFIG_SPELLING_SPEC.get("schema_version") != 1: + raise RuntimeError( + f"unsupported benchmark config spelling schema: {CONFIG_SPELLING_SPEC_PATH}" + ) +DERIVED_RESULTS_AT_PUBLISH_PROFILE = CONFIG_SPELLING_SPEC["profiles"][ + "derived_results_refresh_at_publish" +]["canonical"] +DERIVED_RESULTS_AT_PUBLISH_EXPERIMENT_LABEL = CONFIG_SPELLING_SPEC["experiment_labels"][ + "derived_results_refresh_at_publish" +]["canonical"] +CONFIG_OVERRIDE_SPELLINGS = { + entry["id"]: entry for entry in CONFIG_SPELLING_SPEC["config_overrides"] +} +DERIVED_RESULTS_AT_PUBLISH_OVERRIDE = CONFIG_OVERRIDE_SPELLINGS[ + "incremental_derived_results_refresh_at_publish" +]["canonical"] + +BENCHMARK_ENVIRONMENT_POLICY_PATH = Path(__file__).with_name( + "environment-policy-v1.json" +) +with BENCHMARK_ENVIRONMENT_POLICY_PATH.open(encoding="utf-8") as stream: + BENCHMARK_ENVIRONMENT_POLICY = json.load(stream) +if BENCHMARK_ENVIRONMENT_POLICY.get("schema_version") != 1: + raise RuntimeError( + f"unsupported benchmark environment policy: {BENCHMARK_ENVIRONMENT_POLICY_PATH}" + ) +PRODUCT_ENVIRONMENT_PREFIX = BENCHMARK_ENVIRONMENT_POLICY["product_environment_prefix"] +HARNESS_OWNED_PRODUCT_ENV = frozenset( + BENCHMARK_ENVIRONMENT_POLICY["harness_owned_keys"] +) +BUILD_ENVIRONMENT_KEYS = frozenset( + BENCHMARK_ENVIRONMENT_POLICY["build_environment_keys"] +) + + +SCHEMA_VERSION = 1 +EXPERIMENT_DEFINITION_VERSION = 1 +DEFAULT_MINIMUM_FREE_BYTES = 2 * 1024 * 1024 * 1024 +DEFAULT_STALE_LOCK_SECONDS = 6 * 60 * 60 +FILENAME_DATETIME_FORMAT = "%Y-%m-%d-%H%M%S.%fZ" +DEFAULT_CANDIDATE_REFS = ( + ("upstream-main", "upstream/main"), + ("pre-today-major", "api-consolidation-stable-2026-07-16-semantic-v2"), + ("pre-upstream-merge", "pre-upstream-main-merge-2026-07-19"), + ("latest", "HEAD"), +) +# Fallback chain tried only for the built-in "upstream/main" baseline default, which +# requires a remote literally named "upstream". Era-pinned tags (pre-today-major, +# pre-upstream-merge) and every --candidate-ref override stay fail-closed: an +# unresolvable ref raises rather than silently substituting a different comparison +# point. Once api-consolidation merges to main and "upstream" stops existing, this +# lets --quick/--full keep working without editing DEFAULT_CANDIDATE_REFS. +UPSTREAM_MAIN_FALLBACK_REFS = ("upstream/main", "origin/main", "main") +CANONICAL_BENCHMARK_SCRIPT = Path(__file__).with_name("run_benchmark.py") +LEGACY_BENCHMARK_SCRIPT_SUFFIXES = ( + ("scripts", "benchmark-incremental-speed.py"), + ("benchmarks", "incremental_speed.py"), +) +IDENTITY_FIELDS = ( + "identity_version", + "revision", + "binary_sha256", + "build", + "capabilities", + "capability_support", + "transport", + "scenario", + "repetition", + "harness_version", + "command", + "cwd", + "environment", + "parameters", + "timeout_seconds", + "accepted_exit_codes", +) +BENCHMARK_ARGS_RESERVED_FLAGS = frozenset( + { + "--binary", + "--build-metadata-json", + "--capability-quality", + "--candidate-revision", + "--config", + "--config-profile", + "--facts-dir", + "--frontier-files", + "--include-logs", + "--index-mode", + "--matrix", + "--matrix-scenarios", + "--out", + "--product-env", + "--quality-background-repo", + "--quality-background-revision", + "--repo-root", + "--repo-revision", + "--self-dogfood", + "--self-dogfood-scenarios", + "--timeout", + "--transport", + "--work-root", + } +) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def filename_datetime(moment: datetime | None = None) -> str: + """Return a sortable, filename-safe UTC datetime with collision-level precision.""" + current = moment or datetime.now(timezone.utc) + return current.astimezone(timezone.utc).strftime(FILENAME_DATETIME_FORMAT) + + +def experiment_version() -> str: + """Return the sortable version of the experiment definition, not a run number.""" + return f"v{EXPERIMENT_DEFINITION_VERSION:04d}" + + +def read_experiment_version(document: dict[str, Any]) -> str | None: + """Read the current key or its legacy on-disk spelling without writing the legacy key.""" + current = document.get("experiment_version") + legacy = document.get("campaign_version") + if current is not None and legacy is not None and current != legacy: + raise ValueError("experiment_version conflicts with legacy campaign_version") + value = current if current is not None else legacy + if value is not None and value != experiment_version(): + raise ValueError(f"experiment_version must be {experiment_version()}") + return value + + +def runset_identity(spec_payload: bytes) -> str: + """Identify an immutable runset so preparing the same spec resumes in place.""" + return hashlib.sha256(spec_payload).hexdigest()[:12] + + +def automatic_runset_identity(spec: dict[str, Any]) -> str: + """Hash semantic inputs while allowing an identical runset to be path-remapped.""" + normalized = json.loads(json.dumps(spec)) + normalized.pop("runset_id", None) + normalized.pop("benchmark_script", None) + normalized.pop("cwd", None) + for background_key in ("repository_background", "quality_background"): + background = normalized.get(background_key) + if isinstance(background, dict): + background.pop("repo", None) + candidates = normalized.get("candidates") + if isinstance(candidates, list): + for candidate in candidates: + if isinstance(candidate, dict): + candidate.pop("binary", None) + return runset_identity(canonical_json(normalized)) + + +def _validate_runset_identity(runset: str) -> str: + if len(runset) != 12 or any(char not in "0123456789abcdef" for char in runset): + raise ValueError( + f"runset identity must be 12 lowercase hexadecimal characters: {runset!r}" + ) + return runset + + +def automatic_experiment_name(preset: str, source: dict[str, str], runset: str) -> str: + """Name a resumable experiment without confusing source and execution datetimes.""" + if preset not in {"quick", "full"}: + raise ValueError(f"automatic preset must be quick or full: {preset!r}") + revision = source.get("revision", "") + commit_datetime = source.get("commit_datetime_slug", "") + if len(revision) != 40 or not commit_datetime: + raise ValueError("source must contain a full revision and commit_datetime_slug") + return ( + f"{experiment_version()}-{preset}-commit-{commit_datetime}-{revision[:12]}-" + f"runset-{_validate_runset_identity(runset)}" + ) + + +def automatic_spec_name(preset: str, runset: str) -> str: + if preset not in {"quick", "full"}: + raise ValueError(f"automatic preset must be quick or full: {preset!r}") + return f"spec-{experiment_version()}-{preset}-runset-{_validate_runset_identity(runset)}.json" + + +def generated_artifact_name( + kind: str, + runset: str, + suffix: str, + *, + preset: str | None = None, + moment: datetime | None = None, + nonce: str | None = None, +) -> str: + """Name generated evidence while keeping its stable runset identity visible.""" + if not kind or any(not (char.isalnum() or char == "-") for char in kind): + raise ValueError(f"artifact kind is not path-safe: {kind!r}") + if preset is not None and preset not in {"quick", "full", "custom"}: + raise ValueError(f"artifact preset is invalid: {preset!r}") + if not suffix.startswith(".") or "/" in suffix: + raise ValueError(f"artifact suffix is invalid: {suffix!r}") + if nonce is not None and ( + not nonce or any(not (char.isalnum() or char in "-_") for char in nonce) + ): + raise ValueError(f"artifact nonce is not path-safe: {nonce!r}") + parts = [kind, experiment_version()] + if preset is not None: + parts.append(preset) + parts.extend( + ( + "runset", + _validate_runset_identity(runset), + "generated", + filename_datetime(moment), + ) + ) + if nonce is not None: + parts.append(nonce) + return "-".join(parts) + suffix + + +def _run_text(command: list[str], *, cwd: Path) -> str: + process = subprocess.run( + command, cwd=cwd, capture_output=True, text=True, check=False + ) + if process.returncode != 0: + detail = process.stderr.strip() or process.stdout.strip() or "no diagnostic" + raise RuntimeError( + f"command failed ({process.returncode}): {' '.join(command)}: {detail}" + ) + return process.stdout.strip() + + +def resolve_commit(repository: Path, ref: str) -> str: + """Peel a branch, tag, or commit ref to the full commit object ID.""" + revision = _run_text( + ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], + cwd=repository, + ) + if len(revision) != 40 or any( + char not in "0123456789abcdef" for char in revision.lower() + ): + raise RuntimeError( + f"git resolved {ref!r} to an invalid commit ID: {revision!r}" + ) + return revision.lower() + + +def commit_identity(repository: Path, ref: str) -> dict[str, str]: + """Return a peeled commit, its repository datetime, and its exact tree.""" + revision = resolve_commit(repository, ref) + committed_at = _run_text( + ["git", "show", "-s", "--format=%cI", revision], cwd=repository + ) + try: + parsed = datetime.fromisoformat(committed_at.replace("Z", "+00:00")) + except ValueError as error: + raise RuntimeError( + f"git returned an invalid commit datetime for {revision}: {committed_at!r}" + ) from error + tree = _run_text( + ["git", "rev-parse", "--verify", f"{revision}^{{tree}}"], cwd=repository + ) + return { + "revision": revision, + "committed_at": parsed.isoformat(), + "commit_datetime_slug": parsed.strftime("%Y-%m-%d-%H%M"), + "tree": tree, + } + + +def resolve_default_candidate_ref(repository: Path, label: str, ref: str) -> str: + """Resolve a default candidate ref, retrying survivable baseline aliases only. + + Only the built-in "upstream/main" baseline gets a fallback chain (see + UPSTREAM_MAIN_FALLBACK_REFS), because it is the one default expected to age past + a merge: the remote may be renamed or absent in a fresh clone. Era-pinned tag + defaults and explicit --candidate-ref overrides are not touched here and remain + fail-closed in materialize_candidate: an unresolvable ref raises a clear error + instead of silently running a different comparison. + """ + del label + if ref != "upstream/main": + return ref + for candidate_ref in UPSTREAM_MAIN_FALLBACK_REFS: + try: + resolve_commit(repository, candidate_ref) + except RuntimeError: + continue + return candidate_ref + return ref + + +def parse_candidate_ref_override(value: str) -> tuple[str, str]: + """Parse one repeatable --candidate-ref LABEL=REF argument.""" + label, separator, ref = value.partition("=") + if not separator or not label or not ref: + raise ValueError(f"--candidate-ref must be LABEL=REF: {value!r}") + known_labels = {default_label for default_label, _ in DEFAULT_CANDIDATE_REFS} + if label not in known_labels: + raise ValueError( + f"--candidate-ref label must be one of {sorted(known_labels)}: {label!r}" + ) + return label, ref + + +def _path_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def _candidate_slug(label: str) -> str: + if not label or any(not (char.isalnum() or char in "-_") for char in label): + raise ValueError(f"candidate label is not path-safe: {label!r}") + return label + + +def ensure_clean_tracked_worktree(repository: Path, role: str) -> None: + """Reject tracked edits while allowing ignored retained evidence and build output.""" + tracked_status = _run_text( + ["git", "status", "--porcelain", "--untracked-files=no"], cwd=repository + ) + if tracked_status: + raise RuntimeError( + f"{role} has tracked modifications; commit or restore them before measurement: " + f"{repository}" + ) + + +def _registered_candidate_worktrees( + repository: Path, candidate_roots: list[Path], revision: str +) -> list[Path]: + listing = _run_text(["git", "worktree", "list", "--porcelain"], cwd=repository) + registered: list[Path] = [] + for block in listing.split("\n\n"): + fields: dict[str, str] = {} + for line in block.splitlines(): + key, separator, value = line.partition(" ") + if separator: + fields[key] = value + path_value = fields.get("worktree") + if fields.get("HEAD") == revision and path_value: + registered.append(Path(path_value).resolve()) + matches: list[Path] = [] + seen: set[Path] = set() + for root in candidate_roots: + for candidate in sorted(registered): + if candidate not in seen and _path_within(candidate, root): + matches.append(candidate) + seen.add(candidate) + return matches + + +def _resolved_candidate_search_roots( + candidate_root: Path, candidate_search_roots: list[Path] | None +) -> list[Path]: + roots = [candidate_root] + for value in candidate_search_roots or []: + root = value.expanduser().resolve() + if root in roots: + continue + if not root.is_dir(): + raise ValueError(f"candidate search root is not a directory: {root}") + roots.append(root) + return roots + + +def _make_probe( + worktree: Path, + target: str, + recipe: str, + build_environment: dict[str, str] | None = None, +) -> str: + definition = f"{target}:\n\t@{recipe}\n" + environment = os.environ.copy() + environment.update(build_environment or {}) + make_variables = [ + f"{key}={value}" for key, value in sorted((build_environment or {}).items()) + ] + process = subprocess.run( + ["make", "-s", "-f", "Makefile.cbm", "-f", "-", *make_variables, target], + cwd=worktree, + env=environment, + input=definition, + capture_output=True, + text=True, + check=False, + ) + if process.returncode != 0: + raise RuntimeError(process.stderr.strip() or process.stdout.strip()) + return process.stdout.strip() + + +def _compiler_identity( + worktree: Path, + make_variable: str, + build_environment: dict[str, str] | None = None, +) -> str: + try: + return _make_probe( + worktree, + f"cbm-print-{make_variable.lower()}-identity", + f"$({make_variable}) --version", + build_environment, + ).splitlines()[0] + except (OSError, RuntimeError, IndexError): + return "unknown (see datetime-named build log)" + + +def _production_flags( + worktree: Path, + make_variable: str, + build_environment: dict[str, str] | None = None, +) -> str: + """Read canonical candidate flags without duplicating Makefile definitions.""" + try: + value = _make_probe( + worktree, + f"cbm-print-{make_variable.lower()}", + f"printf '%s\\n' '$({make_variable})'", + build_environment, + ) + except (OSError, RuntimeError): + return "unknown (see candidate Makefile.cbm and build log)" + return value or "not declared by candidate Makefile.cbm" + + +def _candidate_capability_support(label: str) -> dict[str, bool]: + if label == "upstream-main": + return { + "rank": False, + "dependencies": False, + "similarity": True, + "semantic_edges": True, + "git_history": True, + "http_links": False, + } + return { + "rank": True, + "dependencies": True, + "similarity": True, + "semantic_edges": True, + "git_history": True, + "http_links": True, + } + + +def materialize_candidate( + repository: Path, + candidate_root: Path, + label: str, + ref: str, + *, + jobs: int = 2, + build_environment: dict[str, str] | None = None, + candidate_search_roots: list[Path] | None = None, +) -> dict[str, Any]: + """Resolve, isolate, production-build, and hash one benchmark candidate.""" + repository = repository.expanduser().resolve() + candidate_root = candidate_root.expanduser().resolve() + safe_label = _candidate_slug(label) + if jobs <= 0: + raise ValueError("build jobs must be positive") + explicit_build_environment = validate_build_environment( + build_environment, "build_environment" + ) + process_environment = os.environ.copy() + process_environment.update(explicit_build_environment) + source_identity = commit_identity(repository, ref) + revision = source_identity["revision"] + candidate_root.mkdir(parents=True, exist_ok=True) + search_roots = _resolved_candidate_search_roots( + candidate_root, candidate_search_roots + ) + intended = candidate_root / f"{safe_label}-{revision[:12]}" + matches = _registered_candidate_worktrees(repository, search_roots, revision) + if intended in matches: + worktree = intended + elif matches: + worktree = matches[0] + else: + if intended.exists(): + raise RuntimeError( + f"candidate path exists but is not the registered {revision} worktree: {intended}" + ) + process = subprocess.run( + ["git", "worktree", "add", "--detach", str(intended), revision], + cwd=repository, + capture_output=True, + text=True, + check=False, + ) + if process.returncode != 0: + detail = process.stderr.strip() or process.stdout.strip() or "no diagnostic" + raise RuntimeError( + f"could not create candidate worktree {intended}: {detail}" + ) + worktree = intended + actual_revision = resolve_commit(worktree, "HEAD") + if actual_revision != revision: + raise RuntimeError( + f"candidate worktree HEAD mismatch: expected={revision} actual={actual_revision} path={worktree}" + ) + ensure_clean_tracked_worktree(worktree, "candidate worktree") + + binary = worktree / "build" / "c" / "codebase-memory-mcp" + stable_build = { + "target": f"make -j{jobs} -f Makefile.cbm cbm", + "compiler": _compiler_identity(worktree, "CC", explicit_build_environment), + "cflags": _production_flags( + worktree, "CFLAGS_PROD", explicit_build_environment + ), + "cxx_compiler": _compiler_identity(worktree, "CXX", explicit_build_environment), + "cxxflags": _production_flags( + worktree, "CXXFLAGS_PROD", explicit_build_environment + ), + "source_commit_datetime": source_identity["committed_at"], + "source_tree": source_identity["tree"], + } + if explicit_build_environment: + stable_build["environment"] = explicit_build_environment + cache_path = ( + candidate_root + / "cache" + / f"candidate-{experiment_version()}-{safe_label}-commit-{revision[:12]}.json" + ) + if cache_path.is_file() and binary.is_file(): + try: + cached = read_json_object(cache_path).get("candidate") + if ( + isinstance(cached, dict) + and cached.get("label") == safe_label + and cached.get("revision") == revision + and cached.get("binary") == str(binary) + and cached.get("build") == stable_build + and cached.get("tree") == source_identity["tree"] + and cached.get("binary_sha256") == file_sha256(binary) + ): + return cached + except (OSError, ValueError, json.JSONDecodeError): + pass + + stamp = filename_datetime() + log_root = candidate_root / "build-logs" + log_root.mkdir(parents=True, exist_ok=True) + build_log = ( + log_root / f"generated-{stamp}-for-{safe_label}-commit-{revision[:12]}.log" + ) + make_variables = [ + f"{key}={value}" for key, value in explicit_build_environment.items() + ] + clean_command = [ + "make", + "-f", + "Makefile.cbm", + *make_variables, + "clean-c", + ] + command = [ + "make", + f"-j{jobs}", + "-f", + "Makefile.cbm", + *make_variables, + "cbm", + ] + clean_returncode: int | None = None + build_returncode: int | None = None + with build_log.open("w", encoding="utf-8") as stream: + stream.write(f"started_at_utc={utc_now()}\n") + stream.write(f"revision={revision}\n") + if binary.parent.exists(): + # Make does not normally encode compiler, flags, or environment in + # object prerequisites. Once cache identity validation says this is + # a different build, retaining build/c can silently link stale + # objects into a binary whose metadata claims the new toolchain. + stream.write(f"clean_command={' '.join(clean_command)}\n") + stream.flush() + clean_process = subprocess.run( + clean_command, + cwd=worktree, + env=process_environment, + stdout=stream, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + clean_returncode = clean_process.returncode + stream.write(f"clean_exit_code={clean_returncode}\n") + stream.flush() + stream.write(f"command={' '.join(command)}\n") + if clean_returncode in {None, 0}: + stream.flush() + process = subprocess.run( + command, + cwd=worktree, + env=process_environment, + stdout=stream, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + build_returncode = process.returncode + stream.write(f"finished_at_utc={utc_now()}\n") + stream.write( + f"exit_code={build_returncode if build_returncode is not None else clean_returncode}\n" + ) + if clean_returncode not in {None, 0}: + raise RuntimeError( + f"candidate build cleanup failed ({clean_returncode}); see {build_log}" + ) + if build_returncode != 0: + raise RuntimeError( + f"candidate production build failed ({build_returncode}); see {build_log}" + ) + if not binary.is_file(): + raise RuntimeError(f"candidate build did not produce {binary}; see {build_log}") + candidate = { + "label": safe_label, + "revision": revision, + "binary": str(binary), + "binary_sha256": file_sha256(binary), + "build": stable_build, + "capability_support": _candidate_capability_support(safe_label), + "commit_datetime": source_identity["committed_at"], + "tree": source_identity["tree"], + } + metadata_root = candidate_root / "metadata" + metadata_root.mkdir(parents=True, exist_ok=True) + atomic_write_json( + metadata_root + / f"generated-{stamp}-for-{safe_label}-commit-{revision[:12]}.json", + { + **candidate, + "ref": ref, + "worktree": str(worktree), + "build_log": str(build_log), + "recorded_at_utc": utc_now(), + }, + ) + atomic_write_json( + cache_path, + { + "schema_version": SCHEMA_VERSION, + "experiment_version": experiment_version(), + "candidate": candidate, + }, + ) + return candidate + + +def materialize_matrix_candidates( + repository: Path, + candidate_root: Path, + spec: dict[str, Any], + *, + jobs: int, + candidate_search_roots: list[Path] | None = None, +) -> dict[str, Any]: + """Resolve candidate ``ref`` entries into the existing immutable binary schema. + + A reusable matrix spec may name arbitrary branches, tags, or commits without + embedding worktree-specific binary paths. Already resolved candidate entries + remain value-equivalent after the defensive deep copy. + """ + resolved = copy.deepcopy(spec) + build_environment = validate_build_environment( + resolved.get("build_environment"), "build_environment" + ) + candidates = _nonempty_list(resolved.get("candidates"), "candidates") + labels: set[str] = set() + for index, candidate in enumerate(candidates): + if not isinstance(candidate, dict): + raise ValueError(f"candidates[{index}] must be an object") + label = candidate.get("label") + if not isinstance(label, str) or not label: + raise ValueError(f"candidates[{index}].label is invalid") + _candidate_slug(label) + if label in labels: + raise ValueError(f"candidates[{index}].label is duplicated: {label!r}") + labels.add(label) + ref = candidate.get("ref") + if ref is None: + continue + if not isinstance(ref, str) or not ref: + raise ValueError(f"candidates[{index}].ref must be a non-empty string") + conflicting = sorted( + key + for key in ("binary", "binary_sha256", "revision", "build") + if key in candidate + ) + if conflicting: + raise ValueError( + f"candidates[{index}] cannot combine ref with " + ", ".join(conflicting) + ) + capability_support = candidate.get("capability_support") + if not isinstance(capability_support, dict) or not all( + isinstance(key, str) and key and isinstance(value, bool) + for key, value in capability_support.items() + ): + raise ValueError( + f"candidates[{index}].capability_support must explicitly declare " + "string-to-boolean support for a ref-based candidate; the runner " + "cannot infer branch capabilities safely" + ) + _string_map(candidate.get("environment"), f"candidates[{index}].environment") + validate_product_environment( + candidate.get("product_environment"), + f"candidates[{index}].product_environment", + ) + validate_benchmark_args( + candidate.get("benchmark_args"), + f"candidates[{index}].benchmark_args", + ) + + for index, candidate in enumerate(candidates): + ref = candidate.get("ref") + if ref is None: + continue + label = candidate["label"] + passthrough = { + key: copy.deepcopy(candidate[key]) + for key in ( + "benchmark_args", + "capability_support", + "environment", + "product_environment", + ) + if key in candidate + } + materialize_options: dict[str, Any] = { + "jobs": jobs, + "build_environment": build_environment, + } + if candidate_search_roots: + materialize_options["candidate_search_roots"] = candidate_search_roots + materialized = materialize_candidate( + repository, + candidate_root, + label, + ref, + **materialize_options, + ) + materialized.update(passthrough) + candidates[index] = materialized + return resolved + + +def matrix_spec_has_candidate_refs(spec: dict[str, Any]) -> bool: + candidates = spec.get("candidates") + return isinstance(candidates, list) and any( + isinstance(candidate, dict) and "ref" in candidate for candidate in candidates + ) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def artifact_manifest(root: Path) -> dict[str, Any]: + files = [] + total_bytes = 0 + if root.is_dir(): + for path in sorted(item for item in root.rglob("*") if item.is_file()): + size = path.stat().st_size + total_bytes += size + files.append( + { + "path": path.relative_to(root).as_posix(), + "size_bytes": size, + "sha256": file_sha256(path), + } + ) + return {"file_count": len(files), "total_bytes": total_bytes, "files": files} + + +def canonical_json(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def atomic_write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + payload = json.dumps(value, indent=2, sort_keys=True) + "\n" + try: + with temporary.open("w", encoding="utf-8") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + # Some filesystems do not support directory fsync. The file itself + # is still synced before the atomic replacement. + pass + finally: + if temporary.exists(): + temporary.unlink() + + +def atomic_write_bytes(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + try: + with temporary.open("wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def read_json_object(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"expected JSON object: {path}") + return value + + +def is_legacy_benchmark_script_path(value: str) -> bool: + """Return whether value names a retired single-run benchmark location.""" + path_parts = ( + PurePosixPath(value).parts, + PureWindowsPath(value).parts, + ) + return any( + tuple(parts[-len(suffix) :]) == suffix + for parts in path_parts + for suffix in LEGACY_BENCHMARK_SCRIPT_SUFFIXES + ) + + +def resolve_benchmark_script_path(value: str) -> Path: + """Resolve current paths and narrowly migrate retained benchmark script paths.""" + if is_legacy_benchmark_script_path(value): + canonical = CANONICAL_BENCHMARK_SCRIPT.resolve() + if not canonical.is_file(): + raise ValueError(f"canonical benchmark script does not exist: {canonical}") + return canonical + candidate = Path(value).expanduser().resolve() + if not candidate.is_file(): + raise ValueError(f"benchmark script does not exist: {candidate}") + return candidate + + +def build_automatic_spec( + repository: Path, + benchmark_script: Path, + candidates: list[dict[str, Any]], + *, + preset: str, + transport: str = "mcp", + product_environment: dict[str, str] | None = None, +) -> dict[str, Any]: + """Build the canonical safe quick or repeated full capability matrix.""" + if preset not in {"quick", "full"}: + raise ValueError("preset must be quick or full") + if transport not in {"cli", "mcp"}: + raise ValueError("transport must be cli or mcp") + repository = repository.expanduser().resolve() + benchmark_script = benchmark_script.expanduser().resolve() + if not benchmark_script.is_file(): + raise ValueError(f"benchmark script does not exist: {benchmark_script}") + expected_labels = [label for label, _ in DEFAULT_CANDIDATE_REFS] + actual_labels = [candidate.get("label") for candidate in candidates] + if actual_labels != expected_labels: + raise ValueError( + f"automatic candidates must be ordered {expected_labels}, got {actual_labels}" + ) + repository_identity = commit_identity(repository, "HEAD") + repository_revision = repository_identity["revision"] + repository_tree = repository_identity["tree"] + runner_sha = file_sha256(Path(__file__).resolve()) + benchmark_sha = file_sha256(benchmark_script) + latest_labels = [label for label, ref in DEFAULT_CANDIDATE_REFS if ref == "HEAD"] + native_candidate_labels = [ + label for label, ref in DEFAULT_CANDIDATE_REFS if ref != "HEAD" + ] + product_defaults = { + "auto_index_deps": "false", + "rank_enabled": "true", + "similarity_enabled": "true", + "semantic_edges_enabled": "true", + "githistory_enabled": "true", + "httplinks_enabled": "true", + } + + def capabilities(**changes: str) -> dict[str, str]: + values = dict(product_defaults) + values.update(changes) + return values + + profiles: list[dict[str, Any]] = [ + { + "label": "candidate-native-configuration", + "config_profile": "candidate_native_configuration", + "candidate_labels": native_candidate_labels, + "capabilities": {}, + }, + { + "label": "automatic-dependency-source-indexing-disabled", + "config_profile": "automatic_dependency_source_indexing_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(), + }, + ] + if preset == "full": + profiles.extend( + ( + { + "label": "automatic-dependency-source-indexing-enabled", + "config_profile": "automatic_dependency_source_indexing_enabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(auto_index_deps="true"), + }, + { + "label": "upstream-equivalent", + "config_profile": "automatic_dependency_source_indexing_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities( + rank_enabled="false", httplinks_enabled="false" + ), + "config_overrides": { + "auto_index_deps": "false", + "rank_enabled": "false", + "httplinks_enabled": "false", + }, + }, + { + "label": DERIVED_RESULTS_AT_PUBLISH_EXPERIMENT_LABEL, + "config_profile": DERIVED_RESULTS_AT_PUBLISH_PROFILE, + "candidate_labels": latest_labels, + "capabilities": { + **capabilities(), + DERIVED_RESULTS_AT_PUBLISH_OVERRIDE[ + "key" + ]: DERIVED_RESULTS_AT_PUBLISH_OVERRIDE["value"], + }, + }, + { + "label": "rank-disabled", + "config_profile": "rank_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(rank_enabled="false"), + }, + { + "label": "similarity-disabled", + "config_profile": "similarity_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(similarity_enabled="false"), + }, + { + "label": "semantic-edges-disabled", + "config_profile": "semantic_edges_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(semantic_edges_enabled="false"), + }, + { + "label": "git-history-disabled", + "config_profile": "git_history_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(githistory_enabled="false"), + }, + { + "label": "http-links-disabled", + "config_profile": "http_links_disabled", + "candidate_labels": latest_labels, + "capabilities": capabilities(httplinks_enabled="false"), + }, + { + "label": "minimal-indexing", + "config_profile": "minimal_indexing", + "candidate_labels": latest_labels, + "capabilities": capabilities( + rank_enabled="false", + similarity_enabled="false", + semantic_edges_enabled="false", + githistory_enabled="false", + httplinks_enabled="false", + ), + }, + ) + ) + spec = { + "schema_version": SCHEMA_VERSION, + "experiment_version": experiment_version(), + "identity_version": 2, + "harness_version": ( + f"automatic-{preset}:benchmark-{benchmark_sha}:runner-{runner_sha}" + ), + "benchmark_script": str(benchmark_script), + "workload": "self_dogfood", + "repository_background": { + "repo": str(repository), + "revision": repository_revision, + "tree": repository_tree, + "commit_datetime": repository_identity["committed_at"], + }, + "index_mode": "fast" if preset == "quick" else "moderate", + "execution_order": "paired_interleaved", + "cwd": str(repository), + "timeout_seconds": 900, + "cell_timeout_seconds": 1800, + "accepted_exit_codes": [0, 1], + "repetitions": 1 if preset == "quick" else 3, + "transports": [transport], + "candidates": candidates, + "profiles": profiles, + "scenarios": [{"name": "c_new_leaf"}], + } + explicit_product_environment = validate_product_environment( + product_environment, "product_environment" + ) + if explicit_product_environment: + spec["product_environment"] = explicit_product_environment + return spec + + +def identity_document(cell: dict[str, Any]) -> dict[str, Any]: + if cell.get("identity_version") != 2: + return { + key: cell.get(key) for key in IDENTITY_FIELDS if key != "identity_version" + } + document = {key: cell.get(key) for key in IDENTITY_FIELDS} + + command = list(document.get("command") or []) + if command: + command[0] = "{benchmark_script}" + for flag, replacement in ( + ("--binary", "{candidate_binary}"), + ("--repo-root", "{repository_root}"), + ("--quality-background-repo", "{quality_background_root}"), + ): + for index, token in enumerate(command[:-1]): + if token == flag: + command[index + 1] = replacement + document["command"] = command + if document.get("cwd") is not None: + document["cwd"] = "{working_directory}" + parameters = json.loads(json.dumps(document.get("parameters") or {})) + for background_key in ("repository_background", "quality_background"): + background = parameters.get(background_key) + if isinstance(background, dict) and "repo" in background: + background["repo"] = f"{{{background_key}_root}}" + document["parameters"] = parameters + return document + + +def cell_identity(cell: dict[str, Any]) -> str: + return hashlib.sha256(canonical_json(identity_document(cell))).hexdigest()[:24] + + +def validate_cell(cell: dict[str, Any], index: int) -> None: + required = { + "label": str, + "revision": str, + "binary_sha256": str, + "build": dict, + "capabilities": dict, + "transport": str, + "scenario": str, + "repetition": int, + "harness_version": str, + "command": list, + } + for key, expected_type in required.items(): + if not isinstance(cell.get(key), expected_type): + raise ValueError(f"cells[{index}].{key} must be {expected_type.__name__}") + if not cell["label"] or "=" in cell["label"]: + raise ValueError( + f"cells[{index}].label must be non-empty and cannot contain '='" + ) + if len(cell["revision"]) != 40: + raise ValueError( + f"cells[{index}].revision must be a full 40-character commit hash" + ) + if len(cell["binary_sha256"]) != 64: + raise ValueError(f"cells[{index}].binary_sha256 must be a full SHA-256") + if not cell["command"] or not all( + isinstance(item, str) for item in cell["command"] + ): + raise ValueError(f"cells[{index}].command must be a non-empty string array") + if not _is_positive_json_integer(cell["repetition"]): + raise ValueError(f"cells[{index}].repetition must be a positive integer") + timeout_seconds = cell.get("timeout_seconds") + if timeout_seconds is not None and not _is_positive_json_number(timeout_seconds): + raise ValueError( + f"cells[{index}].timeout_seconds must be a positive finite number" + ) + identity_version = cell.get("identity_version", 1) + if not _is_json_integer(identity_version) or identity_version not in {1, 2}: + raise ValueError(f"cells[{index}].identity_version must be 1 or 2") + accepted = cell.get("accepted_exit_codes", [0]) + if ( + not isinstance(accepted, list) + or not accepted + or not all(_is_json_integer(code) for code in accepted) + ): + raise ValueError( + f"cells[{index}].accepted_exit_codes must be a non-empty integer array" + ) + support = cell.get("capability_support") + if support is not None and ( + not isinstance(support, dict) + or not all( + isinstance(key, str) and isinstance(value, bool) + for key, value in support.items() + ) + ): + raise ValueError( + f"cells[{index}].capability_support must be a string-to-boolean object" + ) + + +def validate_plan(plan: dict[str, Any]) -> list[dict[str, Any]]: + if ( + not _is_json_integer(plan.get("schema_version")) + or plan.get("schema_version") != SCHEMA_VERSION + ): + raise ValueError(f"schema_version must be {SCHEMA_VERSION}") + cells = plan.get("cells") + if not isinstance(cells, list) or not cells: + raise ValueError("cells must be a non-empty array") + typed_cells: list[dict[str, Any]] = [] + identities: set[str] = set() + for index, value in enumerate(cells): + if not isinstance(value, dict): + raise ValueError(f"cells[{index}] must be an object") + validate_cell(value, index) + identity = cell_identity(value) + if identity in identities: + raise ValueError(f"duplicate cell identity at cells[{index}]: {identity}") + identities.add(identity) + typed_cells.append(value) + return typed_cells + + +def _string_map(value: Any, field: str) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict) or not all( + isinstance(key, str) and isinstance(item, str) for key, item in value.items() + ): + raise ValueError(f"{field} must be a string-to-string object") + return dict(value) + + +def validate_product_environment(value: Any, field: str) -> dict[str, str]: + values = _string_map(value, field) + for key in values: + if not key.startswith(PRODUCT_ENVIRONMENT_PREFIX): + raise ValueError( + f"{field} key must start with {PRODUCT_ENVIRONMENT_PREFIX}: {key!r}" + ) + if key in HARNESS_OWNED_PRODUCT_ENV: + raise ValueError(f"{field} key is owned by the benchmark harness: {key}") + return values + + +def validate_build_environment(value: Any, field: str) -> dict[str, str]: + values = _string_map(value, field) + unknown = sorted(set(values) - BUILD_ENVIRONMENT_KEYS) + if unknown: + raise ValueError( + f"{field} key is not in the shared build environment policy: " + + ", ".join(unknown) + ) + return dict(sorted(values.items())) + + +def parse_product_environment_arguments(items: list[str]) -> dict[str, str]: + values: dict[str, str] = {} + for item in items: + key, separator, value = item.partition("=") + if not separator or not key or not value: + raise ValueError(f"--product-env must be key=value, got {item!r}") + values[key] = value + return validate_product_environment(values, "--product-env") + + +def validate_benchmark_args(value: Any, field: str) -> list[str]: + """Validate additive workload arguments without surrendering harness ownership.""" + if value is None: + return [] + if not isinstance(value, list) or not all( + isinstance(item, str) and item for item in value + ): + raise ValueError(f"{field} must be a string array") + reserved = sorted( + item + for item in value + if item.partition("=")[0] in BENCHMARK_ARGS_RESERVED_FLAGS + ) + if reserved: + raise ValueError( + f"{field} cannot override experiment-owned flags: " + ", ".join(reserved) + ) + return list(value) + + +def _is_json_integer(value: Any) -> bool: + """Return whether a decoded JSON value is an integer rather than a boolean.""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_positive_json_integer(value: Any) -> bool: + return _is_json_integer(value) and value > 0 + + +def _is_positive_json_number(value: Any) -> bool: + """Accept finite positive JSON numbers while keeping booleans distinct.""" + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and value > 0 + ) + + +def _nonempty_list(value: Any, field: str) -> list[Any]: + if not isinstance(value, list) or not value: + raise ValueError(f"{field} must be a non-empty array") + return value + + +def _optional_iso_datetime(value: Any, field: str) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be an ISO 8601 datetime string") + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError(f"{field} must be an ISO 8601 datetime string") from error + return value + + +def expand_matrix_spec(spec: dict[str, Any]) -> dict[str, Any]: + """Expand a compact benchmark grid into immutable experiment cells.""" + if ( + not _is_json_integer(spec.get("schema_version")) + or spec.get("schema_version") != SCHEMA_VERSION + ): + raise ValueError(f"schema_version must be {SCHEMA_VERSION}") + harness_version = spec.get("harness_version") + benchmark_script = spec.get("benchmark_script") + cwd = spec.get("cwd") + repetitions = spec.get("repetitions") + benchmark_timeout = spec.get("timeout_seconds", 240) + index_mode = spec.get("index_mode", "fast") + accepted_exit_codes = spec.get("accepted_exit_codes", [0]) + capability_quality = spec.get("capability_quality") + workload = spec.get("workload", "matrix") + identity_version = spec.get("identity_version", 1) + execution_order = spec.get("execution_order") + quality_background = spec.get("quality_background") + repository_background = spec.get("repository_background") + if not isinstance(harness_version, str) or not harness_version: + raise ValueError("harness_version must be a non-empty string") + if not isinstance(benchmark_script, str) or not benchmark_script: + raise ValueError("benchmark_script must be a non-empty string") + if not isinstance(cwd, str) or not cwd: + raise ValueError("cwd must be a non-empty string") + if not _is_positive_json_integer(repetitions): + raise ValueError("repetitions must be a positive integer") + if not _is_positive_json_integer(benchmark_timeout): + raise ValueError("timeout_seconds must be a positive integer") + if index_mode not in {"fast", "moderate", "full"}: + raise ValueError("index_mode must be fast, moderate, or full") + if execution_order not in {None, "grouped", "paired_interleaved"}: + raise ValueError("execution_order must be grouped or paired_interleaved") + if capability_quality is not None and ( + not isinstance(capability_quality, str) + or not capability_quality + or "=" in capability_quality + ): + raise ValueError("capability_quality must be a non-empty argument value") + if workload not in {"matrix", "self_dogfood"}: + raise ValueError("workload must be matrix or self_dogfood") + if not _is_json_integer(identity_version) or identity_version not in {1, 2}: + raise ValueError("identity_version must be 1 or 2") + if capability_quality is not None and workload != "matrix": + raise ValueError( + "capability_quality cannot be combined with a self_dogfood workload" + ) + if quality_background is not None: + if capability_quality not in {"similarity", "semantic_edges"}: + raise ValueError( + "quality_background requires capability_quality similarity or semantic_edges" + ) + if not isinstance(quality_background, dict): + raise ValueError("quality_background must be an object") + background_repo = quality_background.get("repo") + background_revision = quality_background.get("revision") + background_tree = quality_background.get("tree") + background_datetime = _optional_iso_datetime( + quality_background.get("commit_datetime"), + "quality_background.commit_datetime", + ) + if not isinstance(background_repo, str) or not Path(background_repo).is_dir(): + raise ValueError("quality_background.repo must be an existing directory") + if not isinstance(background_revision, str) or len(background_revision) != 40: + raise ValueError("quality_background.revision must be a full commit hash") + if not isinstance(background_tree, str) or len(background_tree) != 40: + raise ValueError("quality_background.tree must be a full tree hash") + quality_background = { + "repo": str(Path(background_repo).expanduser().resolve()), + "revision": background_revision, + "tree": background_tree, + } + if background_datetime is not None: + quality_background["commit_datetime"] = background_datetime + if repository_background is not None: + if workload != "self_dogfood": + raise ValueError("repository_background requires workload self_dogfood") + if not isinstance(repository_background, dict): + raise ValueError("repository_background must be an object") + background_repo = repository_background.get("repo") + background_revision = repository_background.get("revision") + background_tree = repository_background.get("tree") + background_datetime = _optional_iso_datetime( + repository_background.get("commit_datetime"), + "repository_background.commit_datetime", + ) + if not isinstance(background_repo, str) or not Path(background_repo).is_dir(): + raise ValueError("repository_background.repo must be an existing directory") + if not isinstance(background_revision, str) or len(background_revision) != 40: + raise ValueError( + "repository_background.revision must be a full commit hash" + ) + if not isinstance(background_tree, str) or len(background_tree) != 40: + raise ValueError("repository_background.tree must be a full tree hash") + repository_background = { + "repo": str(Path(background_repo).expanduser().resolve()), + "revision": background_revision, + "tree": background_tree, + } + if background_datetime is not None: + repository_background["commit_datetime"] = background_datetime + elif workload == "self_dogfood": + raise ValueError("workload self_dogfood requires repository_background") + if ( + not isinstance(accepted_exit_codes, list) + or not accepted_exit_codes + or not all(_is_json_integer(code) for code in accepted_exit_codes) + ): + raise ValueError("accepted_exit_codes must be a non-empty integer array") + cell_timeout = spec.get("cell_timeout_seconds", benchmark_timeout * 4) + if not _is_positive_json_integer(cell_timeout): + raise ValueError("cell_timeout_seconds must be a positive integer") + benchmark_path = resolve_benchmark_script_path(benchmark_script) + benchmark_sha256 = file_sha256(benchmark_path) + + candidates = _nonempty_list(spec.get("candidates"), "candidates") + candidate_labels = { + item.get("label") for item in candidates if isinstance(item, dict) + } + profiles = _nonempty_list(spec.get("profiles"), "profiles") + scenarios = ( + [{"name": f"{capability_quality}_quality"}] + if capability_quality is not None + else _nonempty_list(spec.get("scenarios"), "scenarios") + ) + transports = _nonempty_list(spec.get("transports"), "transports") + if not all(isinstance(item, str) and item for item in transports): + raise ValueError("transports must contain non-empty strings") + common_environment = _string_map(spec.get("environment"), "environment") + common_product_environment = validate_product_environment( + spec.get("product_environment"), "product_environment" + ) + common_benchmark_args = validate_benchmark_args( + spec.get("benchmark_args"), "benchmark_args" + ) + + cells: list[dict[str, Any]] = [] + for candidate_index, candidate in enumerate(candidates): + if not isinstance(candidate, dict): + raise ValueError(f"candidates[{candidate_index}] must be an object") + candidate_label = candidate.get("label") + revision = candidate.get("revision") + binary_value = candidate.get("binary") + build = candidate.get("build") + if ( + not isinstance(candidate_label, str) + or not candidate_label + or "=" in candidate_label + ): + raise ValueError(f"candidates[{candidate_index}].label is invalid") + if not isinstance(revision, str) or len(revision) != 40: + raise ValueError( + f"candidates[{candidate_index}].revision must be a full commit hash" + ) + if not isinstance(binary_value, str) or not binary_value: + raise ValueError( + f"candidates[{candidate_index}].binary must be a path string" + ) + if not isinstance(build, dict): + raise ValueError(f"candidates[{candidate_index}].build must be an object") + binary = Path(binary_value).expanduser().resolve() + if not binary.is_file(): + raise ValueError(f"candidate binary does not exist: {binary}") + binary_sha = file_sha256(binary) + declared_sha = candidate.get("binary_sha256") + if declared_sha is not None and declared_sha != binary_sha: + raise ValueError( + f"candidates[{candidate_index}].binary_sha256 does not match {binary}" + ) + candidate_environment = _string_map( + candidate.get("environment"), f"candidates[{candidate_index}].environment" + ) + candidate_product_environment = validate_product_environment( + candidate.get("product_environment"), + f"candidates[{candidate_index}].product_environment", + ) + candidate_benchmark_args = validate_benchmark_args( + candidate.get("benchmark_args"), + f"candidates[{candidate_index}].benchmark_args", + ) + candidate_support = candidate.get("capability_support") + if candidate_support is not None and ( + not isinstance(candidate_support, dict) + or not all( + isinstance(key, str) and isinstance(value, bool) + for key, value in candidate_support.items() + ) + ): + raise ValueError( + f"candidates[{candidate_index}].capability_support must be a string-to-boolean object" + ) + + for profile_index, profile in enumerate(profiles): + if not isinstance(profile, dict): + raise ValueError(f"profiles[{profile_index}] must be an object") + profile_label = profile.get("label") + config_profile = profile.get("config_profile") + capabilities = profile.get("capabilities") + if ( + not isinstance(profile_label, str) + or not profile_label + or "=" in profile_label + ): + raise ValueError(f"profiles[{profile_index}].label is invalid") + if not isinstance(config_profile, str) or not config_profile: + raise ValueError(f"profiles[{profile_index}].config_profile is invalid") + if not isinstance(capabilities, dict): + raise ValueError( + f"profiles[{profile_index}].capabilities must be an object" + ) + scoped_candidates = profile.get("candidate_labels") + if scoped_candidates is not None: + if ( + not isinstance(scoped_candidates, list) + or not scoped_candidates + or not all( + isinstance(item, str) and item for item in scoped_candidates + ) + ): + raise ValueError( + f"profiles[{profile_index}].candidate_labels must be a non-empty string array" + ) + unknown_candidates = set(scoped_candidates) - candidate_labels + if unknown_candidates: + raise ValueError( + f"profiles[{profile_index}].candidate_labels contains unknown candidates: " + f"{', '.join(sorted(unknown_candidates))}" + ) + if candidate_label not in scoped_candidates: + continue + overrides = _string_map( + profile.get("config_overrides"), + f"profiles[{profile_index}].config_overrides", + ) + if config_profile == "candidate_native_configuration": + for key, claimed_value in capabilities.items(): + expected = str(claimed_value).strip().lower() + if overrides.get(key, "").strip().lower() != expected: + raise ValueError( + f"profiles[{profile_index}].capabilities claims {key}={expected} " + "but the candidate-native profile does not apply that setting; add the " + "same value to config_overrides" + ) + if "incremental_exact_max_affected_paths" in overrides: + raise ValueError( + "exact cap belongs in scenarios[].exact_caps, not profile overrides" + ) + profile_environment = _string_map( + profile.get("environment"), f"profiles[{profile_index}].environment" + ) + profile_product_environment = validate_product_environment( + profile.get("product_environment"), + f"profiles[{profile_index}].product_environment", + ) + profile_benchmark_args = validate_benchmark_args( + profile.get("benchmark_args"), + f"profiles[{profile_index}].benchmark_args", + ) + + for scenario_index, scenario in enumerate(scenarios): + if not isinstance(scenario, dict): + raise ValueError(f"scenarios[{scenario_index}] must be an object") + scenario_name = scenario.get("name") + if not isinstance(scenario_name, str) or not scenario_name: + raise ValueError(f"scenarios[{scenario_index}].name is invalid") + scenario_product_environment = validate_product_environment( + scenario.get("product_environment"), + f"scenarios[{scenario_index}].product_environment", + ) + scenario_benchmark_args = validate_benchmark_args( + scenario.get("benchmark_args"), + f"scenarios[{scenario_index}].benchmark_args", + ) + if capability_quality is not None or workload == "self_dogfood": + frontier_values: list[int | None] = [None] + cap_values: list[int | None] = [None] + else: + frontier_values = _nonempty_list( + scenario.get("frontier_files"), + f"scenarios[{scenario_index}].frontier_files", + ) + cap_values = _nonempty_list( + scenario.get("exact_caps"), + f"scenarios[{scenario_index}].exact_caps", + ) + if not all( + _is_positive_json_integer(item) for item in frontier_values + ): + raise ValueError( + "frontier_files must contain positive integers" + ) + if not all( + item is None or _is_positive_json_integer(item) + for item in cap_values + ): + raise ValueError( + "exact_caps must contain positive integers or null" + ) + + for transport_index, transport in enumerate(transports): + for frontier_files in frontier_values: + for exact_cap in cap_values: + effective_capabilities = dict(capabilities) + effective_capabilities.update(overrides) + if capability_quality is not None: + command = [ + str(benchmark_path), + "--binary", + str(binary), + "--capability-quality", + capability_quality, + "--transport", + transport, + "--config-profile", + config_profile, + "--index-mode", + index_mode, + ] + if quality_background is not None: + command.extend( + ( + "--quality-background-repo", + quality_background["repo"], + "--quality-background-revision", + quality_background["revision"], + ) + ) + elif workload == "self_dogfood": + assert repository_background is not None + command = [ + str(benchmark_path), + "--binary", + str(binary), + "--self-dogfood", + "--repo-root", + repository_background["repo"], + "--repo-revision", + repository_background["revision"], + "--self-dogfood-scenarios", + scenario_name, + "--transport", + transport, + "--config-profile", + config_profile, + "--index-mode", + index_mode, + ] + else: + command = [ + str(benchmark_path), + "--binary", + str(binary), + "--matrix", + "--matrix-scenarios", + scenario_name, + "--frontier-files", + str(frontier_files), + "--transport", + transport, + "--config-profile", + config_profile, + "--index-mode", + index_mode, + ] + cap_label = "default" + if isinstance(exact_cap, int): + effective_capabilities[ + "incremental_exact_max_affected_paths" + ] = str(exact_cap) + command.extend( + ( + "--config", + f"incremental_exact_max_affected_paths={exact_cap}", + ) + ) + cap_label = str(exact_cap) + for key, value in sorted(overrides.items()): + command.extend(("--config", f"{key}={value}")) + product_environment = { + **common_product_environment, + **candidate_product_environment, + **profile_product_environment, + **scenario_product_environment, + } + for key, value in sorted(product_environment.items()): + command.extend(("--product-env", f"{key}={value}")) + benchmark_args = [ + *common_benchmark_args, + *candidate_benchmark_args, + *profile_benchmark_args, + *scenario_benchmark_args, + ] + command.extend(benchmark_args) + if ( + capability_quality is not None + or workload == "self_dogfood" + ): + command.append("--include-logs") + command.extend( + ( + "--timeout", + str(benchmark_timeout), + "--out", + "{result_path}", + ) + ) + parameters = { + "config_profile": config_profile, + "config_overrides": dict(sorted(overrides.items())), + "benchmark_script_sha256": benchmark_sha256, + "index_mode": index_mode, + } + if product_environment: + parameters["product_environment"] = dict( + sorted(product_environment.items()) + ) + if benchmark_args: + parameters["benchmark_args"] = benchmark_args + if capability_quality is not None: + parameters["capability_quality"] = capability_quality + if quality_background is not None: + parameters["quality_background"] = ( + quality_background + ) + label = f"{candidate_label}.{profile_label}.{transport}.{scenario_name}" + elif workload == "self_dogfood": + assert repository_background is not None + parameters["repository_background"] = ( + repository_background + ) + label = f"{candidate_label}.{profile_label}.{transport}.{scenario_name}" + else: + parameters["frontier_files"] = frontier_files + parameters["exact_cap"] = exact_cap + label = ( + f"{candidate_label}.{profile_label}.{transport}." + f"{scenario_name}.f{frontier_files}.cap{cap_label}" + ) + environment = { + **common_environment, + **candidate_environment, + **profile_environment, + } + for repetition in range(1, repetitions + 1): + cell = { + "label": label, + "revision": revision, + "binary_sha256": binary_sha, + "build": build, + "capabilities": effective_capabilities, + "transport": transport, + "scenario": scenario_name, + "repetition": repetition, + "harness_version": harness_version, + "command": command, + "cwd": str(Path(cwd).expanduser().resolve()), + "parameters": parameters, + "timeout_seconds": cell_timeout, + "accepted_exit_codes": list(accepted_exit_codes), + } + if identity_version == 2: + cell["identity_version"] = 2 + if environment: + cell["environment"] = environment + if isinstance(candidate_support, dict): + cell["capability_support"] = dict( + sorted(candidate_support.items()) + ) + cell["_design"] = { + "candidate_index": candidate_index, + "profile_index": profile_index, + "scenario_index": scenario_index, + "transport_index": transport_index, + "grouped_position": len(cells), + } + cells.append(cell) + if execution_order == "paired_interleaved": + cells.sort( + key=lambda cell: ( + cell["repetition"], + cell["_design"]["scenario_index"], + cell["_design"]["transport_index"], + cell["_design"]["candidate_index"], + cell["_design"]["profile_index"], + cell["_design"]["grouped_position"], + ) + ) + for position, cell in enumerate(cells, start=1): + cell["parameters"] = { + **cell["parameters"], + "execution_order": execution_order, + "execution_block": cell["repetition"], + "execution_position": position, + } + for cell in cells: + cell.pop("_design", None) + plan = {"schema_version": SCHEMA_VERSION, "cells": cells} + experiment_definition = read_experiment_version(spec) + if experiment_definition is not None: + plan["experiment_version"] = experiment_definition + runset = spec.get("runset_id") + if runset is not None: + plan["runset_id"] = _validate_runset_identity(runset) + if execution_order is not None: + plan["execution_order"] = execution_order + validate_plan(plan) + return plan + + +def ensure_disk_space(root: Path, minimum_free_bytes: int) -> None: + root.mkdir(parents=True, exist_ok=True) + free = shutil.disk_usage(root).free + if free < minimum_free_bytes: + raise RuntimeError( + f"insufficient experiment disk space: free={free} required={minimum_free_bytes} root={root}" + ) + + +def resource_snapshot(path: Path) -> dict[str, Any]: + disk = shutil.disk_usage(path) + try: + load_average: list[float] | None = [ + round(value, 6) for value in os.getloadavg() + ] + except (AttributeError, OSError): + load_average = None + physical_memory_bytes: int | None = None + try: + pages = int(os.sysconf("SC_PHYS_PAGES")) + page_size = int(os.sysconf("SC_PAGE_SIZE")) + if pages > 0 and page_size > 0: + physical_memory_bytes = pages * page_size + except (AttributeError, OSError, TypeError, ValueError): + pass + return { + "captured_at_utc": utc_now(), + "hostname": socket.gethostname(), + "load_average": load_average, + "cpu_count": os.cpu_count(), + "physical_memory_bytes": physical_memory_bytes, + "disk": { + "path": str(path.resolve()), + "total_bytes": disk.total, + "used_bytes": disk.used, + "free_bytes": disk.free, + }, + } + + +def validate_experiment_root( + root: Path, *, allow_temporary: bool = False, temporary_root: Path | None = None +) -> Path: + """Require retained experiment state to live outside the OS temporary tree.""" + resolved = root.expanduser().resolve() + temp = (temporary_root or Path(tempfile.gettempdir())).expanduser().resolve() + if not allow_temporary and (resolved == temp or temp in resolved.parents): + raise ValueError( + f"experiment root is temporary and may be lost after a crash or reboot: {resolved}; " + "choose a durable ignored path, or pass --allow-temporary-experiment-root only " + "for disposable tests" + ) + return resolved + + +def process_is_live(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def acquire_lock( + cell_root: Path, stale_after_seconds: int +) -> tuple[Path, dict[str, Any] | None]: + cell_root.mkdir(parents=True, exist_ok=True) + lock_path = cell_root / "running.lock" + stale_record: dict[str, Any] | None = None + if lock_path.exists(): + try: + existing = read_json_object(lock_path) + except (OSError, ValueError, json.JSONDecodeError): + existing = {"invalid": True} + try: + started_epoch = float(existing.get("started_epoch", 0.0)) + pid = int(existing.get("pid", -1)) + except (TypeError, ValueError): + started_epoch = 0.0 + pid = -1 + age = time.time() - started_epoch + same_host = existing.get("hostname") == socket.gethostname() + live = same_host and process_is_live(pid) + if live or age < stale_after_seconds: + raise RuntimeError(f"benchmark cell is already locked: {lock_path}") + stale_record = {"recovered_at_utc": utc_now(), "previous_lock": existing} + stale_path = ( + cell_root / f"stale-lock-{filename_datetime()}-{uuid.uuid4().hex[:8]}.json" + ) + atomic_write_json(stale_path, stale_record) + lock_path.unlink() + document = { + "pid": os.getpid(), + "hostname": socket.gethostname(), + "started_at_utc": utc_now(), + "started_epoch": time.time(), + } + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + descriptor = os.open(lock_path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(json.dumps(document, indent=2, sort_keys=True) + "\n") + stream.flush() + os.fsync(stream.fileno()) + return lock_path, stale_record + + +def resolve_result_path(cell_root: Path, completion: dict[str, Any]) -> Path: + relative = completion.get("result_path") + if not isinstance(relative, str): + raise ValueError("completion result_path is missing") + candidate = (cell_root / relative).resolve() + if cell_root.resolve() not in candidate.parents: + raise ValueError("completion result_path escapes the cell directory") + return candidate + + +def validate_result(path: Path, cell: dict[str, Any]) -> dict[str, Any]: + result = read_json_object(path) + metadata = result.get("binary_metadata") + actual_sha = metadata.get("sha256") if isinstance(metadata, dict) else None + if actual_sha != cell["binary_sha256"]: + raise ValueError( + f"result binary SHA-256 mismatch: expected={cell['binary_sha256']} actual={actual_sha}" + ) + if result.get("error"): + raise ValueError(f"benchmark result contains an error: {result['error']}") + run_context = result.get("benchmark_run_context") + if run_context is not None: + if not isinstance(run_context, dict): + raise ValueError("benchmark_run_context must be an object") + expected_context = { + "cell_identity": cell_identity(cell), + "label": cell["label"], + "revision": cell["revision"], + "repetition": cell["repetition"], + "build": cell["build"], + "capabilities": cell["capabilities"], + "capability_support": cell.get("capability_support", {}), + "harness_version": cell["harness_version"], + } + if run_context != expected_context: + raise ValueError("benchmark_run_context does not match the experiment cell") + derived = result.get("derived") + if not isinstance(derived, dict) or not isinstance(derived.get("passed"), bool): + raise ValueError("benchmark result must contain derived.passed as a boolean") + cases = result.get("cases") + measurements = result.get("measurements") + if not (isinstance(cases, list) and cases) and not isinstance(measurements, dict): + raise ValueError( + "benchmark result must contain non-empty cases or measurements" + ) + expected_background = cell.get("parameters", {}).get("quality_background") + if expected_background is not None: + first_case = cases[0] if isinstance(cases, list) and cases else None + actual_background = ( + first_case.get("background_repository") + if isinstance(first_case, dict) + else None + ) + if not isinstance(actual_background, dict): + raise ValueError( + "benchmark result is missing background_repository identity" + ) + for key in ("revision", "tree"): + if actual_background.get(key) != expected_background.get(key): + raise ValueError( + f"background repository {key} mismatch: " + f"expected={expected_background.get(key)} actual={actual_background.get(key)}" + ) + expected_repository = cell.get("parameters", {}).get("repository_background") + if expected_repository is not None: + actual_repository = result.get("repository_background") + if not isinstance(actual_repository, dict): + raise ValueError( + "benchmark result is missing repository_background identity" + ) + for key in ("revision", "tree"): + if actual_repository.get(key) != expected_repository.get(key): + raise ValueError( + f"repository background {key} mismatch: " + f"expected={expected_repository.get(key)} " + f"actual={actual_repository.get(key)}" + ) + return result + + +def validate_attempt_artifacts(cell_root: Path, completion: dict[str, Any]) -> None: + """Re-hash a completed attempt's archived evidence before trusting its audit status.""" + attempt_id = completion.get("attempt") + if attempt_id is None: + # Historical hand-authored plans may predate per-attempt evidence. Their + # result hash remains validated, but there is no artifact claim to check. + return + if ( + not isinstance(attempt_id, str) + or not attempt_id + or Path(attempt_id).name != attempt_id + or attempt_id in {".", ".."} + ): + raise ValueError("completion attempt identifier is invalid") + attempt_root = cell_root / "attempts" / attempt_id + attempt = read_json_object(attempt_root / "attempt.json") + if attempt.get("cell_identity") != completion.get("cell_identity"): + raise ValueError("attempt cell identity does not match the completion") + if attempt.get("status") != "completed": + raise ValueError("completed cell references a non-completed attempt") + expected = attempt.get("artifacts") + if not isinstance(expected, dict): + raise ValueError("completed attempt artifact manifest is missing") + actual = artifact_manifest(attempt_root / "artifacts") + if actual != expected: + raise ValueError( + "completed attempt artifact manifest does not match retained files" + ) + + +def valid_completion(cell_root: Path, cell: dict[str, Any]) -> dict[str, Any] | None: + completion_path = cell_root / "complete.json" + if not completion_path.is_file(): + return None + completion = read_json_object(completion_path) + if completion.get("cell_identity") != cell_identity(cell): + raise ValueError("completion cell identity does not match the plan") + result_path = resolve_result_path(cell_root, completion) + validate_result(result_path, cell) + if file_sha256(result_path) != completion.get("result_sha256"): + raise ValueError("completion result SHA-256 does not match the retained result") + validate_attempt_artifacts(cell_root, completion) + return completion + + +def expanded_command( + command: list[str], attempt_root: Path, result_path: Path +) -> list[str]: + replacements = { + "{attempt_dir}": str(attempt_root), + "{result_path}": str(result_path), + } + expanded = [replacements.get(item, item) for item in command] + if expanded and is_legacy_benchmark_script_path(expanded[0]): + expanded[0] = str(resolve_benchmark_script_path(expanded[0])) + return expanded + + +def validate_benchmark_script_digest( + command: list[str], parameters: dict[str, Any] +) -> None: + """Reject a retained cell when its resolved harness bytes changed.""" + expected = parameters.get("benchmark_script_sha256") + if expected is None: + return + if not isinstance(expected, str) or len(expected) != 64: + raise ValueError("benchmark_script_sha256 must be a SHA-256 string") + if not command: + raise ValueError("benchmark command must not be empty") + script = Path(command[0]).expanduser().resolve() + if not script.is_file(): + raise ValueError(f"benchmark script does not exist: {script}") + actual = file_sha256(script) + if actual != expected: + raise ValueError( + "benchmark script SHA-256 mismatch after path resolution: " + f"expected {expected}, got {actual}; generate a new experiment plan" + ) + + +def cell_process_group_options() -> dict[str, Any]: + if os.name == "nt": + return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + return {"start_new_session": True} + + +def stop_cell_process_tree( + process: subprocess.Popen[bytes], initial_signal: int, grace_seconds: float = 30.0 +) -> int | None: + """Stop an isolated benchmark process group, allowing harness cleanup first.""" + if process.poll() is not None: + return process.returncode + with suppress(OSError, ProcessLookupError): + if os.name == "nt": + process.send_signal(signal.CTRL_BREAK_EVENT) + else: + os.killpg(process.pid, initial_signal) + try: + return process.wait(timeout=grace_seconds) + except subprocess.TimeoutExpired: + pass + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + with suppress(OSError, ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + try: + return process.wait(timeout=10) + except subprocess.TimeoutExpired: + return process.poll() + + +def run_cell( + experiment_root: Path, + cell: dict[str, Any], + *, + minimum_free_bytes: int = DEFAULT_MINIMUM_FREE_BYTES, + stale_lock_seconds: int = DEFAULT_STALE_LOCK_SECONDS, +) -> dict[str, Any]: + validate_cell(cell, 0) + ensure_disk_space(experiment_root, minimum_free_bytes) + identity = cell_identity(cell) + cell_root = experiment_root / "runs" / identity + try: + completion = valid_completion(cell_root, cell) + except (OSError, ValueError, json.JSONDecodeError) as exc: + return { + "cell_identity": identity, + "label": cell["label"], + "status": "corrupt", + "error": str(exc), + } + if completion is not None: + return {"cell_identity": identity, "label": cell["label"], "status": "resumed"} + + lock_path, stale_record = acquire_lock(cell_root, stale_lock_seconds) + try: + attempt_id = filename_datetime() + f"-{uuid.uuid4().hex[:8]}" + attempt_root = cell_root / "attempts" / attempt_id + attempt_root.mkdir(parents=True) + artifact_root = attempt_root / "artifacts" + result_path = attempt_root / "result.json" + command = expanded_command(cell["command"], attempt_root, result_path) + validate_benchmark_script_digest(command, cell.get("parameters") or {}) + cwd = Path(cell.get("cwd") or Path.cwd()).expanduser().resolve() + environment = dict(os.environ) + overrides = cell.get("environment", {}) + if not isinstance(overrides, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in overrides.items() + ): + raise ValueError("cell environment must be a string-to-string object") + environment.update(overrides) + environment["CBM_BENCHMARK_ARTIFACT_DIR"] = str(artifact_root) + benchmark_run_context = { + "cell_identity": identity, + "label": cell["label"], + "revision": cell["revision"], + "repetition": cell["repetition"], + "build": cell["build"], + "capabilities": cell["capabilities"], + "capability_support": cell.get("capability_support", {}), + "harness_version": cell["harness_version"], + } + environment["CBM_BENCHMARK_RUN_CONTEXT"] = canonical_json( + benchmark_run_context + ).decode("utf-8") + command_record = { + "cell_identity": identity, + "identity": identity_document(cell), + "label": cell["label"], + "command": command, + "cwd": str(cwd), + "environment_overrides": overrides, + "benchmark_run_context": benchmark_run_context, + "artifact_directory": "artifacts", + "started_at_utc": utc_now(), + "stale_lock_recovered": stale_record is not None, + "resource_before": resource_snapshot(experiment_root), + } + atomic_write_json(attempt_root / "command.json", command_record) + started = time.monotonic() + returncode: int | None = None + error: str | None = None + interrupted = False + except Exception: + if lock_path.exists(): + lock_path.unlink() + raise + try: + with ( + (attempt_root / "stdout.log").open("wb") as stdout, + (attempt_root / "stderr.log").open("wb") as stderr, + ): + try: + process = subprocess.Popen( + command, + cwd=cwd, + env=environment, + stdout=stdout, + stderr=stderr, + **cell_process_group_options(), + ) + returncode = process.wait(timeout=cell.get("timeout_seconds")) + except subprocess.TimeoutExpired as exc: + error = f"command timed out after {exc.timeout} seconds" + returncode = stop_cell_process_tree(process, signal.SIGTERM) + except KeyboardInterrupt: + error = "command interrupted by SIGINT" + interrupted = True + returncode = stop_cell_process_tree(process, signal.SIGINT) + accepted_codes = cell.get("accepted_exit_codes", [0]) + if error is None and returncode not in accepted_codes: + error = f"command exited with {returncode}; accepted={accepted_codes}" + result: dict[str, Any] | None = None + if error is None: + try: + result = validate_result(result_path, cell) + except (OSError, ValueError, json.JSONDecodeError) as exc: + error = str(exc) + attempt_record = { + **command_record, + "finished_at_utc": utc_now(), + "elapsed_seconds": round(time.monotonic() - started, 6), + "returncode": returncode, + "status": "completed" if error is None else "failed", + "error": error, + "resource_after": resource_snapshot(experiment_root), + "artifacts": artifact_manifest(artifact_root), + } + atomic_write_json(attempt_root / "attempt.json", attempt_record) + if interrupted: + raise KeyboardInterrupt + if error is not None: + return { + "cell_identity": identity, + "label": cell["label"], + "status": "failed", + "error": error, + "attempt": attempt_id, + } + assert result is not None + derived = result.get("derived") + benchmark_passed = derived.get("passed") if isinstance(derived, dict) else None + completion = { + "cell_identity": identity, + "label": cell["label"], + "completed_at_utc": utc_now(), + "attempt": attempt_id, + "result_path": str(result_path.relative_to(cell_root)), + "result_sha256": file_sha256(result_path), + "returncode": returncode, + "benchmark_passed": benchmark_passed, + } + atomic_write_json(cell_root / "complete.json", completion) + return { + "cell_identity": identity, + "label": cell["label"], + "status": "completed", + } + finally: + if lock_path.exists(): + lock_path.unlink() + + +def scan_experiment( + experiment_root: Path, cells: list[dict[str, Any]] +) -> dict[str, Any]: + expected = {cell_identity(cell): cell for cell in cells} + entries: list[dict[str, Any]] = [] + counts = { + "complete": 0, + "missing": 0, + "corrupt": 0, + "duplicate_attempts": 0, + "unplanned": 0, + } + for identity, cell in expected.items(): + cell_root = experiment_root / "runs" / identity + attempts_root = cell_root / "attempts" + attempt_count = ( + sum(1 for path in attempts_root.iterdir() if path.is_dir()) + if attempts_root.is_dir() + else 0 + ) + if attempt_count > 1: + counts["duplicate_attempts"] += attempt_count - 1 + status = "missing" + error = None + try: + if valid_completion(cell_root, cell) is not None: + status = "complete" + except (OSError, ValueError, json.JSONDecodeError) as exc: + status = "corrupt" + error = str(exc) + counts[status] += 1 + entries.append( + { + "cell_identity": identity, + "label": cell["label"], + "status": status, + "attempts": attempt_count, + "error": error, + } + ) + runs_root = experiment_root / "runs" + actual = ( + {path.name for path in runs_root.iterdir() if path.is_dir()} + if runs_root.is_dir() + else set() + ) + unplanned = sorted(actual - set(expected)) + counts["unplanned"] = len(unplanned) + return {"counts": counts, "cells": entries, "unplanned": unplanned} + + +def environment_snapshot(plan_path: Path) -> dict[str, Any]: + return { + "captured_at_utc": utc_now(), + "plan_path": str(plan_path.resolve()), + "plan_sha256": file_sha256(plan_path), + "hostname": socket.gethostname(), + "platform": platform.platform(), + "python": sys.version, + "cpu_count": os.cpu_count(), + "resources": resource_snapshot(plan_path.parent), + } + + +def completed_report_inputs( + experiment_root: Path, cells: list[dict[str, Any]] +) -> list[tuple[str, Path]]: + inputs: list[tuple[str, Path]] = [] + for cell in cells: + cell_root = experiment_root / "runs" / cell_identity(cell) + completion = valid_completion(cell_root, cell) + if completion is not None: + result_path = resolve_result_path(cell_root, completion) + inputs.append( + ( + cell["label"], + materialize_report_input(experiment_root, cell, result_path), + ) + ) + return inputs + + +def completed_fact_inputs( + experiment_root: Path, cells: list[dict[str, Any]] +) -> tuple[list[Path], list[str]]: + inputs: list[Path] = [] + missing: list[str] = [] + for cell in cells: + identity = cell_identity(cell) + cell_root = experiment_root / "runs" / identity + completion = valid_completion(cell_root, cell) + if completion is None: + continue + attempt = completion.get("attempt") + if not isinstance(attempt, str) or not attempt: + missing.append(identity) + continue + path = cell_root / "attempts" / attempt / "artifacts" / "facts" / "facts.json" + if path.is_file(): + inputs.append(path) + else: + missing.append(identity) + return inputs, missing + + +def generate_fact_comparisons( + experiment_root: Path, + cells: list[dict[str, Any]], + report_output: Path, +) -> dict[str, Any]: + inputs, missing = completed_fact_inputs(experiment_root, cells) + if missing or len(inputs) != len(cells): + return { + "status": "unavailable", + "reason": "one or more retained completed cells predate canonical fact bundles", + "fact_input_count": len(inputs), + "missing_cell_identities": sorted(missing), + } + comparison_output = report_output.with_suffix(".comparisons.json") + appendix_output = report_output.with_suffix(".fact-appendix.md") + generator = Path(__file__).resolve().with_name("fact_comparisons.py") + command = [sys.executable, str(generator)] + for path in inputs: + command.extend(("--fact", str(path))) + command.extend( + ( + "--out", + str(comparison_output), + "--markdown-out", + str(appendix_output), + ) + ) + process = subprocess.run(command, capture_output=True, text=True, check=False) + if process.returncode != 0: + raise RuntimeError( + f"fact comparison generator exited with {process.returncode}: " + f"{process.stderr.strip()}" + ) + appendix = appendix_output.read_text(encoding="utf-8") + report = report_output.read_text(encoding="utf-8").rstrip() + atomic_write_bytes( + report_output, (report + "\n\n" + appendix.rstrip() + "\n").encode("utf-8") + ) + return { + "status": "generated", + "path": str(comparison_output), + "sha256": file_sha256(comparison_output), + "appendix_path": str(appendix_output), + "appendix_sha256": file_sha256(appendix_output), + "fact_input_count": len(inputs), + "generator": str(generator), + } + + +def materialize_report_input( + experiment_root: Path, cell: dict[str, Any], result_path: Path +) -> Path: + """Create a deterministic derived input with candidate metadata beside immutable raw results.""" + document = read_json_object(result_path) + parameters = document.get("parameters") + if not isinstance(parameters, dict): + parameters = {} + document["parameters"] = parameters + support = cell.get("capability_support") + if isinstance(support, dict): + parameters["capability_support"] = dict(sorted(support.items())) + cell_parameters = cell.get("parameters") + if isinstance(cell_parameters, dict): + for key in ("execution_order", "execution_block", "execution_position"): + if key in cell_parameters: + parameters[key] = cell_parameters[key] + source_sha = file_sha256(result_path) + identity = cell_identity(cell) + document["experiment_provenance"] = { + "cell_identity": identity, + "source_result": str(result_path), + "source_result_sha256": source_sha, + } + output = ( + experiment_root / "reports" / "inputs" / f"{identity}-{source_sha[:12]}.json" + ) + atomic_write_json(output, document) + return output + + +def generate_report( + experiment_root: Path, cells: list[dict[str, Any]], output: Path +) -> dict[str, Any]: + inputs = completed_report_inputs(experiment_root, cells) + if not inputs: + raise RuntimeError( + "cannot generate a report without completed experiment cells" + ) + summarizer = Path(__file__).resolve().with_name("summarize_results.py") + command = [sys.executable, str(summarizer)] + for label, result_path in inputs: + command.extend(("--input", f"{label}={result_path}")) + command.extend(("--out", str(output))) + process = subprocess.run(command, capture_output=True, text=True, check=False) + if process.returncode != 0: + raise RuntimeError( + f"report generator exited with {process.returncode}: {process.stderr.strip()}" + ) + fact_comparisons = generate_fact_comparisons(experiment_root, cells, output) + return { + "path": str(output), + "sha256": file_sha256(output), + "input_count": len(inputs), + "generator": str(summarizer), + "fact_comparisons": fact_comparisons, + } + + +def write_manifest( + experiment_root: Path, + plan_path: Path, + cells: list[dict[str, Any]], + report: dict[str, Any] | None = None, + *, + runset: str | None = None, +) -> Path: + manifest = { + "schema_version": SCHEMA_VERSION, + "generated_at_utc": utc_now(), + "plan_sha256": file_sha256(plan_path), + "audit": scan_experiment(experiment_root, cells), + "generated_report": report, + } + effective_runset = runset or file_sha256(plan_path)[:12] + name = generated_artifact_name( + "manifest", + effective_runset, + ".json", + nonce=uuid.uuid4().hex[:8], + ) + path = experiment_root / "manifests" / name + atomic_write_json(path, manifest) + return path + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group() + source.add_argument( + "--plan", type=Path, help="Fully expanded immutable experiment plan." + ) + source.add_argument( + "--matrix-spec", + type=Path, + help="Compact deterministic grid expanded and archived before execution.", + ) + source.add_argument( + "--quick", + dest="preset", + action="store_const", + const="quick", + help="Automatically prepare and run the safe one-repetition smoke (default).", + ) + source.add_argument( + "--full", + dest="preset", + action="store_const", + const="full", + help="Automatically prepare and run the repeated capability matrix.", + ) + parser.add_argument( + "--experiment-root", + "--campaign-root", + dest="experiment_root", + type=Path, + help=( + "Durable result root (--campaign-root is a legacy alias). " + "Automatic modes default to a versioned, commit-qualified, " + "content-addressed runset directory under " + ".worktrees/benchmark-campaign (legacy path retained for existing runsets)." + ), + ) + parser.add_argument( + "--candidate-root", + type=Path, + help=( + "Candidate worktree/build root for automatic presets and ref-based matrix " + "specs (default: .worktrees/benchmark-candidates)." + ), + ) + parser.add_argument( + "--candidate-search-root", + dest="candidate_search_roots", + action="append", + type=Path, + default=[], + help=( + "Additional existing root containing registered candidate worktrees. " + "Repeat in preferred search order; new worktrees and metadata remain " + "under --candidate-root." + ), + ) + parser.add_argument("--build-jobs", type=int, default=2) + parser.add_argument( + "--allow-temporary-experiment-root", + "--allow-temporary-campaign-root", + dest="allow_temporary_experiment_root", + action="store_true", + help="Allow disposable experiment state under the OS temporary directory.", + ) + parser.add_argument( + "--candidate-ref", + dest="candidate_ref_overrides", + action="append", + default=[], + metavar="LABEL=REF", + help=( + "Override one automatic candidate's git ref by label (repeatable), e.g. " + "--candidate-ref upstream-main=origin/main. Valid labels: " + + ", ".join(label for label, _ in DEFAULT_CANDIDATE_REFS) + + ". Only applies to --quick/--full; explicit --plan/--matrix-spec already " + "accept any resolvable ref directly in the spec. An override is an explicit " + "request and stays fail-closed: an unresolvable override ref raises rather " + "than falling back." + ), + ) + parser.add_argument( + "--transport", + choices=("cli", "mcp"), + default="mcp", + help=( + "Transport for automatic --quick/--full cells (default: mcp). " + "Cross-build cli cells require an isolated OS account/runtime or a quiescent " + "account-wide CBM daemon; mcp requires one compatible build." + ), + ) + parser.add_argument( + "--product-env", + action="append", + default=[], + metavar="CBM_KEY=VALUE", + help=( + "Explicit candidate environment for automatic --quick/--full runs. " + "Repeat for controlled resource sweeps; harness-owned isolation keys " + "are rejected." + ), + ) + parser.add_argument("--minimum-free-gb", type=float, default=2.0) + parser.add_argument("--stale-lock-hours", type=float, default=6.0) + parser.add_argument("--audit-only", action="store_true") + parser.add_argument( + "--report-out", + type=Path, + help="Generated Markdown path (default: versioned runset report under EXPERIMENT_ROOT/reports).", + ) + return parser + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + parser = build_parser() + args = parser.parse_args(argv) + if args.plan is None and args.matrix_spec is None and args.preset is None: + args.preset = "quick" + if args.preset is None and args.experiment_root is None: + parser.error( + "--experiment-root (legacy alias: --campaign-root) is required with --plan or --matrix-spec" + ) + if args.build_jobs <= 0: + parser.error("--build-jobs must be positive") + candidate_ref_overrides: dict[str, str] = {} + for value in args.candidate_ref_overrides: + try: + label, ref = parse_candidate_ref_override(value) + except ValueError as error: + parser.error(str(error)) + candidate_ref_overrides[label] = ref + if candidate_ref_overrides and args.preset is None: + parser.error("--candidate-ref only applies to --quick/--full") + if args.transport != "mcp" and args.preset is None: + parser.error("--transport only applies to --quick/--full") + if args.product_env and args.preset is None: + parser.error("--product-env only applies to --quick/--full") + try: + args.product_environment = parse_product_environment_arguments(args.product_env) + except ValueError as error: + parser.error(str(error)) + args.candidate_ref = candidate_ref_overrides + return args + + +def _commit_datetime_slug(repository: Path, revision: str) -> str: + return commit_identity(repository, revision)["commit_datetime_slug"] + + +def prepare_automatic_experiment( + args: argparse.Namespace, +) -> tuple[Path, Path]: + repository = Path(__file__).resolve().parents[1] + ensure_clean_tracked_worktree(repository, "benchmark source worktree") + candidate_root = ( + args.candidate_root.expanduser().resolve() + if args.candidate_root + else repository / ".worktrees" / "benchmark-candidates" + ) + ensure_disk_space(candidate_root, max(0, int(args.minimum_free_gb * 1024**3))) + candidate_ref_overrides: dict[str, str] = getattr(args, "candidate_ref", {}) or {} + effective_candidate_refs = [ + (label, candidate_ref_overrides[label]) + if label in candidate_ref_overrides + else (label, resolve_default_candidate_ref(repository, label, ref)) + for label, ref in DEFAULT_CANDIDATE_REFS + ] + candidates = [ + materialize_candidate( + repository, + candidate_root, + label, + ref, + jobs=args.build_jobs, + candidate_search_roots=args.candidate_search_roots, + ) + for label, ref in effective_candidate_refs + ] + benchmark_script = CANONICAL_BENCHMARK_SCRIPT + spec = build_automatic_spec( + repository, + benchmark_script, + candidates, + preset=args.preset, + transport=args.transport, + product_environment=args.product_environment, + ) + revision = spec["repository_background"]["revision"] + tree = spec["repository_background"]["tree"] + commit_datetime = _commit_datetime_slug(repository, revision) + runset = automatic_runset_identity(spec) + spec["runset_id"] = runset + spec_payload = (json.dumps(spec, indent=2, sort_keys=True) + "\n").encode("utf-8") + source_identity = { + "revision": revision, + "commit_datetime_slug": commit_datetime, + "tree": tree, + } + experiment_root = ( + args.experiment_root.expanduser().resolve() + if args.experiment_root + else repository + / ".worktrees" + / "benchmark-campaign" + / automatic_experiment_name(args.preset, source_identity, runset) + ) + experiment_root = validate_experiment_root( + experiment_root, + allow_temporary=args.allow_temporary_experiment_root, + ) + spec_path = experiment_root / "inputs" / automatic_spec_name(args.preset, runset) + if spec_path.exists(): + if spec_path.read_bytes() != spec_payload: + raise RuntimeError( + f"automatic spec path contains different bytes: {spec_path}" + ) + else: + atomic_write_bytes(spec_path, spec_payload) + return experiment_root, spec_path + + +def main(argv: list[str] | None = None) -> int: + args = parse_arguments(argv) + + if args.preset is not None: + experiment_root, matrix_spec = prepare_automatic_experiment(args) + args.matrix_spec = matrix_spec + else: + assert args.experiment_root is not None + experiment_root = validate_experiment_root( + args.experiment_root, + allow_temporary=args.allow_temporary_experiment_root, + ) + + minimum_free_bytes = max(0, int(args.minimum_free_gb * 1024**3)) + stale_lock_seconds = max(1, int(args.stale_lock_hours * 3600)) + ensure_disk_space(experiment_root, minimum_free_bytes) + if args.matrix_spec: + spec_path = args.matrix_spec.expanduser().resolve() + spec = read_json_object(spec_path) + if matrix_spec_has_candidate_refs(spec): + repository = Path(__file__).resolve().parents[1] + ensure_clean_tracked_worktree( + repository, "ref-based benchmark source worktree" + ) + candidate_root = ( + args.candidate_root.expanduser().resolve() + if args.candidate_root + else repository / ".worktrees" / "benchmark-candidates" + ) + ensure_disk_space(candidate_root, minimum_free_bytes) + source_spec_sha256 = file_sha256(spec_path) + source_archive = ( + experiment_root / "specs" / f"source-{source_spec_sha256}.json" + ) + if not source_archive.exists(): + atomic_write_bytes(source_archive, spec_path.read_bytes()) + spec = materialize_matrix_candidates( + repository, + candidate_root, + spec, + jobs=args.build_jobs, + candidate_search_roots=args.candidate_search_roots, + ) + runset = automatic_runset_identity(spec) + declared_runset = spec.get("runset_id") + if declared_runset is not None and declared_runset != runset: + raise ValueError( + "ref-based matrix spec runset_id does not match its resolved " + f"candidates: declared={declared_runset} resolved={runset}" + ) + spec["runset_id"] = runset + spec["source_matrix_spec_sha256"] = source_spec_sha256 + resolved_payload = ( + json.dumps(spec, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + spec_path = ( + experiment_root + / "inputs" + / f"spec-{experiment_version()}-custom-runset-{runset}.json" + ) + if spec_path.exists(): + if spec_path.read_bytes() != resolved_payload: + raise RuntimeError( + f"resolved matrix spec path contains different bytes: {spec_path}" + ) + else: + atomic_write_bytes(spec_path, resolved_payload) + plan = expand_matrix_spec(spec) + plan["matrix_spec_sha256"] = file_sha256(spec_path) + archived_spec = experiment_root / "specs" / f"{file_sha256(spec_path)}.json" + if not archived_spec.exists(): + atomic_write_bytes(archived_spec, spec_path.read_bytes()) + plan_payload = (json.dumps(plan, indent=2, sort_keys=True) + "\n").encode( + "utf-8" + ) + plan_digest = hashlib.sha256(plan_payload).hexdigest() + plan_path = experiment_root / "plans" / f"{plan_digest}.json" + if not plan_path.exists(): + atomic_write_bytes(plan_path, plan_payload) + else: + plan_path = args.plan.expanduser().resolve() + plan = read_json_object(plan_path) + archived_plan = experiment_root / "plans" / f"{file_sha256(plan_path)}.json" + if not archived_plan.exists(): + atomic_write_bytes(archived_plan, plan_path.read_bytes()) + plan_path = archived_plan + cells = validate_plan(plan) + runset = plan.get("runset_id", file_sha256(plan_path)[:12]) + runset = _validate_runset_identity(runset) + snapshot_name = generated_artifact_name("environment", runset, ".json") + atomic_write_json( + experiment_root / "environments" / snapshot_name, + environment_snapshot(plan_path), + ) + + failures = 0 + if not args.audit_only: + for cell in cells: + outcome = run_cell( + experiment_root, + cell, + minimum_free_bytes=minimum_free_bytes, + stale_lock_seconds=stale_lock_seconds, + ) + print(json.dumps(outcome, sort_keys=True), flush=True) + failures += int(outcome["status"] in {"failed", "corrupt"}) + audit = scan_experiment(experiment_root, cells) + report_metadata = None + if audit["counts"]["complete"]: + report_path = ( + args.report_out.expanduser().resolve() + if args.report_out + else experiment_root + / "reports" + / generated_artifact_name( + "report", + runset, + ".md", + preset=args.preset or "custom", + ) + ) + report_metadata = generate_report(experiment_root, cells, report_path) + manifest_path = write_manifest( + experiment_root, + plan_path, + cells, + report_metadata, + runset=runset, + ) + print( + json.dumps( + {"manifest": str(manifest_path), "audit": audit}, indent=2, sort_keys=True + ) + ) + return ( + 1 if failures or audit["counts"]["missing"] or audit["counts"]["corrupt"] else 0 + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/schema/comparisons-v1.schema.json b/benchmarks/schema/comparisons-v1.schema.json new file mode 100644 index 000000000..87fd75358 --- /dev/null +++ b/benchmarks/schema/comparisons-v1.schema.json @@ -0,0 +1,379 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "benchmark-comparisons-v1.schema.json", + "title": "Codebase Memory benchmark fact-derived comparisons", + "type": "object", + "required": [ + "$schema", + "schema_version", + "generated_at_utc", + "terminology_version", + "terminology_sha256", + "joins", + "formulas", + "source_bundles", + "cell_groups", + "comparisons", + "lifecycle_rows" + ], + "properties": { + "$schema": {"const": "benchmarks/schema/comparisons-v1.schema.json"}, + "schema_version": {"const": 1}, + "generated_at_utc": {"type": "string", "format": "date-time"}, + "terminology_version": {"type": "string", "minLength": 1}, + "terminology_sha256": {"$ref": "#/$defs/sha256"}, + "joins": { + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/join"} + }, + "formulas": { + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/formula"} + }, + "source_bundles": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/sourceBundle"} + }, + "cell_groups": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/cellGroup"} + }, + "comparisons": { + "type": "array", + "items": {"$ref": "#/$defs/comparison"} + }, + "lifecycle_rows": { + "type": "array", + "items": {"$ref": "#/$defs/lifecycleRow"} + } + }, + "additionalProperties": false, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "contentId": { + "type": "string", + "pattern": "^[0-9a-f]{24}$" + }, + "stringArray": { + "type": "array", + "items": {"type": "string"} + }, + "join": { + "type": "object", + "required": [ + "join_id", + "fields", + "unknown_values_allowed", + "ratio_allowed" + ], + "properties": { + "join_id": { + "enum": [ + "parity_manifest_and_contract_v1", + "capability_delta_manifest_v1" + ] + }, + "fields": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "string"} + }, + "unknown_values_allowed": {"type": "boolean"}, + "ratio_allowed": {"type": "boolean"} + }, + "additionalProperties": false + }, + "formula": { + "type": "object", + "required": ["formula_id", "expression"], + "properties": { + "formula_id": { + "enum": [ + "median_elapsed_ms_v1", + "left_elapsed_divided_by_right_elapsed_v1" + ] + }, + "expression": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "sourceBundle": { + "type": "object", + "required": ["path", "sha256", "schema_version", "run_id"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "sha256": {"$ref": "#/$defs/sha256"}, + "schema_version": {"enum": [1, 2]}, + "run_id": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "stepAggregate": { + "type": "object", + "required": [ + "step_id", + "formula_id", + "count", + "median_elapsed_ms", + "min_elapsed_ms", + "max_elapsed_ms", + "source_occurrence_ids" + ], + "properties": { + "step_id": {"type": "string", "minLength": 1}, + "formula_id": {"const": "median_elapsed_ms_v1"}, + "count": {"type": "integer", "minimum": 1}, + "median_elapsed_ms": {"type": "number", "minimum": 0}, + "min_elapsed_ms": {"type": "number", "minimum": 0}, + "max_elapsed_ms": {"type": "number", "minimum": 0}, + "source_occurrence_ids": {"$ref": "#/$defs/stringArray"} + }, + "additionalProperties": false + }, + "resultAggregate": { + "type": "object", + "required": [ + "result_id", + "kind", + "statuses", + "all_passed", + "source_run_ids" + ], + "properties": { + "result_id": {"type": "string", "minLength": 1}, + "kind": {"type": "string", "minLength": 1}, + "statuses": {"$ref": "#/$defs/stringArray"}, + "all_passed": {"type": "boolean"}, + "source_run_ids": {"$ref": "#/$defs/stringArray"} + }, + "additionalProperties": false + }, + "cellGroup": { + "type": "object", + "required": [ + "cell_group_id", + "label", + "mode", + "implementation", + "capabilities", + "scope", + "cache", + "host", + "benchmark_contract", + "result_contract", + "source_run_ids", + "source_fact_paths", + "step_aggregates", + "result_aggregates" + ], + "properties": { + "cell_group_id": {"$ref": "#/$defs/contentId"}, + "label": {"type": "string", "minLength": 1}, + "mode": {"type": "string", "minLength": 1}, + "implementation": {"type": "object"}, + "capabilities": {"type": "object"}, + "scope": {"type": "object"}, + "cache": {"type": "object"}, + "host": {"type": "object"}, + "benchmark_contract": {"type": "object"}, + "result_contract": {"type": "array", "items": {"type": "object"}}, + "source_run_ids": { + "allOf": [ + {"$ref": "#/$defs/stringArray"}, + {"minItems": 1, "uniqueItems": true} + ] + }, + "source_fact_paths": { + "allOf": [ + {"$ref": "#/$defs/stringArray"}, + {"minItems": 1, "uniqueItems": true} + ] + }, + "step_aggregates": { + "type": "array", + "items": {"$ref": "#/$defs/stepAggregate"} + }, + "result_aggregates": { + "type": "array", + "items": {"$ref": "#/$defs/resultAggregate"} + } + }, + "additionalProperties": false + }, + "capabilityDifference": { + "type": "object", + "required": ["capability_id", "left", "right"], + "properties": { + "capability_id": {"type": "string", "minLength": 1}, + "left": {}, + "right": {} + }, + "additionalProperties": false + }, + "stepComparison": { + "type": "object", + "required": [ + "step_id", + "formula_id", + "left_median_elapsed_ms", + "right_median_elapsed_ms", + "left_elapsed_divided_by_right_elapsed", + "left_source_occurrence_ids", + "right_source_occurrence_ids" + ], + "properties": { + "step_id": {"type": "string", "minLength": 1}, + "formula_id": { + "const": "left_elapsed_divided_by_right_elapsed_v1" + }, + "left_median_elapsed_ms": {"type": "number", "minimum": 0}, + "right_median_elapsed_ms": {"type": "number", "minimum": 0}, + "left_elapsed_divided_by_right_elapsed": { + "type": ["number", "null"], + "minimum": 0 + }, + "left_source_occurrence_ids": {"$ref": "#/$defs/stringArray"}, + "right_source_occurrence_ids": {"$ref": "#/$defs/stringArray"} + }, + "additionalProperties": false + }, + "comparison": { + "type": "object", + "required": [ + "comparison_id", + "left_cell_group_id", + "right_cell_group_id", + "left_source_run_ids", + "right_source_run_ids", + "comparison_kind", + "join_id", + "ratio_allowed", + "capability_differences", + "step_comparisons", + "limitations" + ], + "properties": { + "comparison_id": {"$ref": "#/$defs/contentId"}, + "left_cell_group_id": {"$ref": "#/$defs/contentId"}, + "right_cell_group_id": {"$ref": "#/$defs/contentId"}, + "left_source_run_ids": {"$ref": "#/$defs/stringArray"}, + "right_source_run_ids": {"$ref": "#/$defs/stringArray"}, + "comparison_kind": { + "enum": [ + "parity_comparison", + "capability_delta_comparison", + "not_eligible" + ] + }, + "join_id": { + "enum": [ + "parity_manifest_and_contract_v1", + "capability_delta_manifest_v1", + null + ] + }, + "ratio_allowed": {"type": "boolean"}, + "capability_differences": { + "type": "array", + "items": {"$ref": "#/$defs/capabilityDifference"} + }, + "step_comparisons": { + "type": "array", + "items": {"$ref": "#/$defs/stepComparison"} + }, + "limitations": {"$ref": "#/$defs/stringArray"} + }, + "allOf": [ + { + "if": { + "properties": {"comparison_kind": {"const": "parity_comparison"}}, + "required": ["comparison_kind"] + }, + "then": { + "properties": { + "join_id": {"const": "parity_manifest_and_contract_v1"}, + "ratio_allowed": {"const": true}, + "capability_differences": {"maxItems": 0}, + "limitations": {"maxItems": 0} + } + } + }, + { + "if": { + "properties": { + "comparison_kind": {"const": "capability_delta_comparison"} + }, + "required": ["comparison_kind"] + }, + "then": { + "properties": { + "join_id": {"const": "capability_delta_manifest_v1"}, + "ratio_allowed": {"const": false}, + "capability_differences": {"minItems": 1}, + "step_comparisons": {"maxItems": 0} + } + } + }, + { + "if": { + "properties": {"comparison_kind": {"const": "not_eligible"}}, + "required": ["comparison_kind"] + }, + "then": { + "properties": { + "join_id": {"type": "null"}, + "ratio_allowed": {"const": false}, + "step_comparisons": {"maxItems": 0}, + "limitations": {"minItems": 1} + } + } + } + ], + "additionalProperties": false + }, + "lifecycleRow": { + "type": "object", + "required": [ + "cell_group_id", + "label", + "source_run_ids", + "steps", + "wall_time_rule" + ], + "properties": { + "cell_group_id": {"$ref": "#/$defs/contentId"}, + "label": {"type": "string", "minLength": 1}, + "source_run_ids": {"$ref": "#/$defs/stringArray"}, + "steps": { + "type": "array", + "items": { + "allOf": [ + {"$ref": "#/$defs/stepAggregate"}, + { + "properties": { + "step_id": { + "enum": [ + "initial_index", + "incremental_index", + "clean_rebuild_index" + ] + } + } + } + ] + } + }, + "wall_time_rule": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + } + } +} diff --git a/benchmarks/schema/facts-v2.schema.json b/benchmarks/schema/facts-v2.schema.json new file mode 100644 index 000000000..19b940225 --- /dev/null +++ b/benchmarks/schema/facts-v2.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "benchmark-facts-v2.schema.json", + "title": "Codebase Memory benchmark fact bundle", + "type": "object", + "required": [ + "$schema", + "schema_version", + "terminology_version", + "terminology_sha256", + "generator_revision", + "runs", + "steps", + "results", + "artifacts" + ], + "properties": { + "$schema": {"const": "benchmarks/schema/facts-v2.schema.json"}, + "schema_version": {"const": 2}, + "terminology_version": { + "type": "string", + "pattern": "^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$" + }, + "terminology_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "generator_revision": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "runs": { + "description": "Run-level identity and measurement conditions; exactly one row per fact bundle.", + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": {"$ref": "#/$defs/run"} + }, + "steps": { + "description": "Measured operation occurrences. Rows may overlap in wall time and are not additive unless a report proves serial execution.", + "type": "array", + "items": {"$ref": "#/$defs/step"} + }, + "results": { + "description": "Correctness, quality, and instrumentation outcomes for the run.", + "type": "array", + "items": {"$ref": "#/$defs/result"} + }, + "artifacts": { + "description": "Content-identified files retained as measurement evidence.", + "type": "array", + "items": {"$ref": "#/$defs/artifact"} + } + }, + "additionalProperties": false, + "$defs": { + "unknown": { + "description": "A value the measurement source did not record; reason states the missing evidence.", + "type": "object", + "required": ["status", "reason"], + "properties": { + "status": {"const": "unknown"}, + "reason": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "runId": {"type": "string", "pattern": "^[0-9a-f]{24}$"}, + "run": { + "type": "object", + "required": [ + "run_id", "lifecycle_id", "generated_at_utc", "mode", "implementation", + "harness", "host", "measurement_checkout", "capabilities", "scope", "cache", "legacy_import" + ], + "properties": { + "run_id": {"$ref": "#/$defs/runId", "description": "Content-derived identity for this measured lifecycle."}, + "lifecycle_id": {"$ref": "#/$defs/runId", "description": "Identity of the user-observable process-to-gate lifecycle; equal to run_id in schema version 1."}, + "generated_at_utc": {"description": "Recorded report completion time, or an explicit unknown fact."}, + "mode": {"type": "string", "minLength": 1, "description": "Benchmark workload/report family."}, + "cell_identity": {"description": "Immutable experiment-cell identity, or an explicit unknown fact for standalone and legacy runs."}, + "cell_label": {"description": "Human-readable experiment-cell label, or an explicit unknown fact."}, + "repetition": {"description": "One-based repetition declared by the experiment, or an explicit unknown fact."}, + "implementation": {"type": "object", "description": "Candidate revision, revision provenance, binary identity, and build metadata."}, + "harness": {"type": "object", "description": "Benchmark script path, SHA-256, and fact-schema version."}, + "host": {"type": "object", "description": "Host facts recorded by the measurement process, or an explicit unknown fact."}, + "measurement_checkout": {"type": "object", "description": "Git checkout that executed the harness; it is not evidence of the candidate binary revision."}, + "capabilities": {"type": "object", "description": "Resolved capability/configuration values plus completeness and provenance."}, + "scope": {"type": "object", "description": "Workload identity, corpus or fixture bounds, and mutation size."}, + "cache": {"type": "object", "description": "Known process, graph, dependency, OS, parser, and fixture cache states."}, + "legacy_import": {"type": "boolean", "description": "True when a retained report was normalized without recorded measurement-process context."} + }, + "additionalProperties": false + }, + "step": { + "type": "object", + "required": [ + "run_id", "step_id", "occurrence_id", "source_path", "parent_occurrence_id", + "dependency_occurrence_ids", "elapsed_ms", "monotonic_start_ns", + "monotonic_end_ns", "cpu_ms", "cpu_scope", "queue_wait_ms", + "thread_or_worker_id", "critical_path", "peak_rss_mb", "work_counters", + "provenance" + ], + "properties": { + "run_id": {"$ref": "#/$defs/runId"}, + "step_id": {"type": "string", "minLength": 1, "description": "Stable operation-class label; multiple occurrences may share it."}, + "occurrence_id": {"type": "string", "pattern": "^[0-9a-f]{24}$", "description": "Identity of this operation occurrence within the run."}, + "source_path": {"type": "string", "description": "JSON path from which the measurement was normalized."}, + "parent_occurrence_id": {"type": ["string", "null"], "description": "Containing occurrence; containment does not imply serial execution."}, + "dependency_occurrence_ids": {"type": "array", "items": {"type": "string"}, "description": "Recorded prerequisite occurrences; an empty array means no dependency evidence was recorded."}, + "elapsed_ms": {"type": "number", "minimum": 0, "description": "Wall-clock duration. Overlapping occurrence durations must not be summed."}, + "monotonic_start_ns": {"description": "Monotonic start timestamp or an explicit unknown fact."}, + "monotonic_end_ns": {"description": "Monotonic end timestamp or an explicit unknown fact."}, + "cpu_ms": {"description": "CPU time consumed by cpu_scope, or an explicit unknown fact."}, + "cpu_scope": {"type": "string", "description": "Entity covered by cpu_ms, such as thread, process, or process tree."}, + "queue_wait_ms": {"description": "Runnable-to-execution delay or an explicit unknown fact."}, + "thread_or_worker_id": {"description": "Recorded execution resource identity or an explicit unknown fact."}, + "critical_path": {"description": "Whether and how this occurrence lies on the measured dependency critical path, or an explicit unknown fact."}, + "peak_rss_mb": {"description": "Peak resident memory attributable to the occurrence, or an explicit unknown fact."}, + "work_counters": {"type": "object", "description": "Operation-specific item counts with named units."}, + "provenance": {"type": "string", "description": "Measurement source or normalization rule that produced the row."} + }, + "additionalProperties": false + }, + "result": { + "type": "object", + "required": ["run_id", "result_id", "kind", "status", "value", "provenance"], + "properties": { + "run_id": {"$ref": "#/$defs/runId"}, + "result_id": {"type": "string", "minLength": 1}, + "kind": {"type": "string", "minLength": 1}, + "status": {"enum": ["passed", "failed", "unknown", "skipped"]}, + "value": {}, + "provenance": {"type": "string"} + }, + "additionalProperties": false + }, + "artifact": { + "type": "object", + "required": [ + "run_id", "artifact_id", "artifact_type", "path", "sha256", "size_bytes", + "schema_version", "cleanup_status" + ], + "properties": { + "run_id": {"$ref": "#/$defs/runId"}, + "artifact_id": {"type": "string", "pattern": "^[0-9a-f]{24}$"}, + "artifact_type": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size_bytes": {}, + "schema_version": {}, + "cleanup_status": {"type": "string"} + }, + "additionalProperties": false + } + } +} diff --git a/benchmarks/schema/terminology.schema.json b/benchmarks/schema/terminology.schema.json new file mode 100644 index 000000000..26b516704 --- /dev/null +++ b/benchmarks/schema/terminology.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "benchmark-terminology.schema.json", + "title": "Codebase Memory benchmark terminology registry", + "type": "object", + "required": [ + "$schema", + "schema_version", + "terminology_version", + "step_id_order", + "entries" + ], + "properties": { + "$schema": { + "const": "benchmarks/schema/terminology.schema.json" + }, + "schema_version": { + "const": 1 + }, + "terminology_version": { + "type": "string", + "pattern": "^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$" + }, + "step_id_order": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/termId"} + }, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/entry" + } + } + }, + "additionalProperties": false, + "$defs": { + "termId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "entry": { + "type": "object", + "required": [ + "term_id", + "display_name", + "definition", + "status", + "kind", + "data_type", + "allowed_values_or_range", + "unit", + "clock_or_cpu_scope", + "boundary_semantics", + "aggregation_rule", + "concurrency_rule", + "missing_or_unsupported_behavior", + "configuration_precedence", + "capability_or_freshness_implications", + "source_anchors", + "introduced_version", + "deprecated_replacement", + "examples" + ], + "properties": { + "term_id": {"$ref": "#/$defs/termId"}, + "display_name": {"type": "string", "minLength": 1}, + "definition": {"type": "string", "minLength": 1}, + "status": {"enum": ["existing", "proposed", "deprecated"]}, + "kind": {"type": "string", "minLength": 1}, + "data_type": {"type": "string", "minLength": 1}, + "allowed_values_or_range": {"type": "string", "minLength": 1}, + "unit": {"type": "string", "minLength": 1}, + "clock_or_cpu_scope": {"type": "string", "minLength": 1}, + "boundary_semantics": {"type": "string", "minLength": 1}, + "aggregation_rule": {"type": "string", "minLength": 1}, + "concurrency_rule": {"type": "string", "minLength": 1}, + "missing_or_unsupported_behavior": {"type": "string", "minLength": 1}, + "configuration_precedence": {"type": "string", "minLength": 1}, + "capability_or_freshness_implications": {"type": "string", "minLength": 1}, + "source_anchors": { + "type": "array", + "minItems": 1, + "items": {"type": "string", "minLength": 1} + }, + "introduced_version": {"type": "string", "minLength": 1}, + "deprecated_replacement": { + "oneOf": [ + {"$ref": "#/$defs/termId"}, + {"type": "null"} + ] + }, + "examples": { + "type": "array", + "items": {} + } + }, + "additionalProperties": false + } + } +} diff --git a/benchmarks/semantic-pairs-v1/cbmq_records.py b/benchmarks/semantic-pairs-v1/cbmq_records.py new file mode 100644 index 000000000..2b543a032 --- /dev/null +++ b/benchmarks/semantic-pairs-v1/cbmq_records.py @@ -0,0 +1,46 @@ +def cbmq_sanitize(value: str) -> str: + return value.strip().lower() + + +def cbmq_lookup(table: dict, key: str) -> str: + return table.get(key, "") + + +def cbmq_audit_log(message: str) -> None: + print(message) + + +def cbmq_normalize_user_record(record: dict, table: dict) -> dict: + """Normalize a user record by sanitizing fields and looking up defaults.""" + result = {} + name = cbmq_sanitize(record.get("name", "")) + email = cbmq_sanitize(record.get("email", "")) + role = cbmq_lookup(table, name) + if name and email: + result["name"] = name + result["email"] = email + result["role"] = role + cbmq_audit_log("normalized user record") + return result + + +def cbmq_normalize_account_record(record: dict, table: dict) -> dict: + """Normalize an account record by sanitizing fields and looking up defaults.""" + result = {} + name = cbmq_sanitize(record.get("name", "")) + email = cbmq_sanitize(record.get("email", "")) + role = cbmq_lookup(table, name) + while name and email: + result["name"] = name + result["email"] = email + result["role"] = role + cbmq_audit_log("normalized account record") + break + return result + + +def cbmq_archive_record_decoy(record: dict, table: dict) -> dict: + """Archive record metadata without normalization.""" + keys = sorted(record.keys()) + bucket = table.get("archive", "") + return {"bucket": bucket, "fields": keys, "count": len(keys)} diff --git a/benchmarks/semantic-pairs-v1/cbmq_similarity.go b/benchmarks/semantic-pairs-v1/cbmq_similarity.go new file mode 100644 index 000000000..e68fb0cb7 --- /dev/null +++ b/benchmarks/semantic-pairs-v1/cbmq_similarity.go @@ -0,0 +1,92 @@ +package cbmq + +import ( + "errors" + "strings" +) + +func cbmqValidateUser(u User) error { + if u.Name == "" { + return errors.New("name required") + } + if len(u.Name) > 100 { + return errors.New("name too long") + } + if u.Age < 0 { + return errors.New("invalid age") + } + if u.Age > 200 { + return errors.New("age too high") + } + if u.Email == "" { + return errors.New("email required") + } + if !strings.Contains(u.Email, "@") { + return errors.New("invalid email") + } + if u.Phone == "" { + return errors.New("phone required") + } + if len(u.Phone) < 7 { + return errors.New("phone too short") + } + if u.Country == "" { + return errors.New("country required") + } + for _, value := range u.Tags { + if value == "" { + return errors.New("empty tag") + } + } + return nil +} + +func cbmqValidateOrder(o Order) error { + if o.Title == "" { + return errors.New("title required") + } + if len(o.Title) > 100 { + return errors.New("title too long") + } + if o.Amount < 0 { + return errors.New("invalid amount") + } + if o.Amount > 200 { + return errors.New("amount too high") + } + if o.Status == "" { + return errors.New("status required") + } + if !strings.Contains(o.Status, "@") { + return errors.New("invalid status") + } + if o.Region == "" { + return errors.New("region required") + } + if len(o.Region) < 7 { + return errors.New("region too short") + } + if o.Vendor == "" { + return errors.New("vendor required") + } + for _, value := range o.Items { + if value == "" { + return errors.New("empty item") + } + } + return nil +} + +func cbmqValidateProfileDecoy(p Profile) error { + values := map[string]string{"name": p.Name, "email": p.Email} + missing := make([]string, 0) + for field, value := range values { + if strings.TrimSpace(value) == "" { + missing = append(missing, field) + } + } + if len(missing) != 0 { + return errors.New(strings.Join(missing, ",")) + } + return nil +} diff --git a/benchmarks/semantic-pairs-v1/manifest.json b/benchmarks/semantic-pairs-v1/manifest.json new file mode 100644 index 000000000..85faf2aaa --- /dev/null +++ b/benchmarks/semantic-pairs-v1/manifest.json @@ -0,0 +1,112 @@ +{ + "schema_version": 1, + "task_set_version": "semantic-pairs-v1", + "ground_truth_scope": "explicit generated canary pairs only", + "query_name_marker": "cbmq", + "cases": { + "similarity": { + "capability": "similarity", + "relationship": "SIMILAR_TO", + "score_property": "jaccard", + "languages": ["go"], + "source_paths": ["cbmq_similarity.go"], + "judgments": [ + { + "source": "cbmqValidateUser", + "target": "cbmqValidateOrder", + "expected": true, + "category": "structural_near_clone" + }, + { + "source": "cbmqValidateUser", + "target": "cbmqValidateProfileDecoy", + "expected": false, + "category": "lexical_hard_negative" + }, + { + "source": "cbmqValidateOrder", + "target": "cbmqValidateProfileDecoy", + "expected": false, + "category": "lexical_hard_negative" + } + ], + "mutation": { + "target_path": "cbmq_similarity.go", + "replacement_source_path": "variants/cbmq_similarity_after.go", + "description": "move the structural-clone relationship from order to profile", + "post_judgments": [ + { + "source": "cbmqValidateUser", + "target": "cbmqValidateOrder", + "expected": false, + "category": "removed_structural_near_clone" + }, + { + "source": "cbmqValidateUser", + "target": "cbmqValidateProfileDecoy", + "expected": true, + "category": "added_structural_near_clone" + }, + { + "source": "cbmqValidateOrder", + "target": "cbmqValidateProfileDecoy", + "expected": false, + "category": "lexical_hard_negative" + } + ] + } + }, + "semantic_edges": { + "capability": "semantic_edges", + "relationship": "SEMANTICALLY_RELATED", + "score_property": "score", + "languages": ["python"], + "source_paths": ["cbmq_records.py"], + "judgments": [ + { + "source": "cbmq_normalize_user_record", + "target": "cbmq_normalize_account_record", + "expected": true, + "category": "semantic_control_flow_variant" + }, + { + "source": "cbmq_normalize_user_record", + "target": "cbmq_archive_record_decoy", + "expected": false, + "category": "lexical_hard_negative" + }, + { + "source": "cbmq_normalize_account_record", + "target": "cbmq_archive_record_decoy", + "expected": false, + "category": "lexical_hard_negative" + } + ], + "mutation": { + "target_path": "cbmq_records.py", + "replacement_source_path": "variants/cbmq_records_after.py", + "description": "move semantic relatedness from account normalization to archive normalization", + "post_judgments": [ + { + "source": "cbmq_normalize_user_record", + "target": "cbmq_normalize_account_record", + "expected": false, + "category": "removed_semantic_control_flow_variant" + }, + { + "source": "cbmq_normalize_user_record", + "target": "cbmq_archive_record_decoy", + "expected": true, + "category": "added_semantic_control_flow_variant" + }, + { + "source": "cbmq_normalize_account_record", + "target": "cbmq_archive_record_decoy", + "expected": false, + "category": "lexical_hard_negative" + } + ] + } + } + } +} diff --git a/benchmarks/semantic-pairs-v1/variants/cbmq_records_after.py b/benchmarks/semantic-pairs-v1/variants/cbmq_records_after.py new file mode 100644 index 000000000..41e72af72 --- /dev/null +++ b/benchmarks/semantic-pairs-v1/variants/cbmq_records_after.py @@ -0,0 +1,47 @@ +def cbmq_sanitize(value: str) -> str: + return value.strip().lower() + + +def cbmq_lookup(table: dict, key: str) -> str: + return table.get(key, "") + + +def cbmq_audit_log(message: str) -> None: + print(message) + + +def cbmq_normalize_user_record(record: dict, table: dict) -> dict: + """Normalize a user record by sanitizing fields and looking up defaults.""" + result = {} + name = cbmq_sanitize(record.get("name", "")) + email = cbmq_sanitize(record.get("email", "")) + role = cbmq_lookup(table, name) + if name and email: + result["name"] = name + result["email"] = email + result["role"] = role + cbmq_audit_log("normalized user record") + return result + + +def cbmq_normalize_account_record(record: dict, table: dict) -> dict: + """Archive account field names without normalization.""" + keys = sorted(record.keys()) + bucket = table.get("archive", "") + return {"bucket": bucket, "fields": keys, "count": len(keys)} + + +def cbmq_archive_record_decoy(record: dict, table: dict) -> dict: + """Normalize an archive record by sanitizing fields and looking up defaults.""" + result = {} + name = cbmq_sanitize(record.get("name", "")) + email = cbmq_sanitize(record.get("email", "")) + role = cbmq_lookup(table, name) + for _ in range(1): + if not (name and email): + continue + result["name"] = name + result["email"] = email + result["role"] = role + cbmq_audit_log("normalized archive record") + return result diff --git a/benchmarks/semantic-pairs-v1/variants/cbmq_similarity_after.go b/benchmarks/semantic-pairs-v1/variants/cbmq_similarity_after.go new file mode 100644 index 000000000..9acfefd52 --- /dev/null +++ b/benchmarks/semantic-pairs-v1/variants/cbmq_similarity_after.go @@ -0,0 +1,92 @@ +package cbmq + +import ( + "errors" + "strings" +) + +func cbmqValidateUser(u User) error { + if u.Name == "" { + return errors.New("name required") + } + if len(u.Name) > 100 { + return errors.New("name too long") + } + if u.Age < 0 { + return errors.New("invalid age") + } + if u.Age > 200 { + return errors.New("age too high") + } + if u.Email == "" { + return errors.New("email required") + } + if !strings.Contains(u.Email, "@") { + return errors.New("invalid email") + } + if u.Phone == "" { + return errors.New("phone required") + } + if len(u.Phone) < 7 { + return errors.New("phone too short") + } + if u.Country == "" { + return errors.New("country required") + } + for _, value := range u.Tags { + if value == "" { + return errors.New("empty tag") + } + } + return nil +} + +func cbmqValidateOrder(o Order) error { + values := map[string]string{"title": o.Title, "status": o.Status} + missing := make([]string, 0) + for field, value := range values { + if strings.TrimSpace(value) == "" { + missing = append(missing, field) + } + } + if len(missing) != 0 { + return errors.New(strings.Join(missing, ",")) + } + return nil +} + +func cbmqValidateProfileDecoy(p Profile) error { + if p.Name == "" { + return errors.New("name required") + } + if len(p.Name) > 100 { + return errors.New("name too long") + } + if p.Age < 0 { + return errors.New("invalid age") + } + if p.Age > 200 { + return errors.New("age too high") + } + if p.Email == "" { + return errors.New("email required") + } + if !strings.Contains(p.Email, "@") { + return errors.New("invalid email") + } + if p.Phone == "" { + return errors.New("phone required") + } + if len(p.Phone) < 7 { + return errors.New("phone too short") + } + if p.Country == "" { + return errors.New("country required") + } + for _, value := range p.Tags { + if value == "" { + return errors.New("empty tag") + } + } + return nil +} diff --git a/benchmarks/summarize_results.py b/benchmarks/summarize_results.py new file mode 100755 index 000000000..1eda64c51 --- /dev/null +++ b/benchmarks/summarize_results.py @@ -0,0 +1,2779 @@ +#!/usr/bin/env python3 +"""Aggregate existing CBM benchmark JSON into a quality-first Markdown table.""" + +from __future__ import annotations + +import argparse +from contextlib import suppress +import hashlib +import importlib.util +import json +import math +import os +import statistics +import uuid +from collections import defaultdict +from pathlib import Path +from typing import Any + + +CONFIG_SPELLING_SPEC_PATH = Path(__file__).with_name("config-spellings-v1.json") +with CONFIG_SPELLING_SPEC_PATH.open(encoding="utf-8") as stream: + CONFIG_SPELLING_SPEC = json.load(stream) +if CONFIG_SPELLING_SPEC.get("schema_version") != 1: + raise RuntimeError( + f"unsupported benchmark config spelling schema: {CONFIG_SPELLING_SPEC_PATH}" + ) + + +def percentile(values: list[float], quantile: float) -> float | None: + if not values: + return None + ordered = sorted(values) + index = max(0, math.ceil(quantile * len(ordered)) - 1) + return float(ordered[index]) + + +def repeated_query_elapsed_ms(oracle: dict[str, Any]) -> float | None: + summary = oracle.get("repeated_json_latency_ms") + if isinstance(summary, dict) and isinstance(summary.get("median"), (int, float)): + return float(summary["median"]) + elapsed = oracle.get("elapsed_ms") + return float(elapsed) if isinstance(elapsed, (int, float)) else None + + +def ratio(passed: int, applicable: int) -> str: + return f"{passed}/{applicable}" if applicable else "n/a" + + +def evidence_lifecycle(reports: list[dict[str, Any]]) -> str: + """Describe retained evidence separately from requested cleanup outcomes.""" + disposed = 0 + retained = 0 + failed = 0 + unknown = 0 + for report in reports: + cleanup = report.get("cleanup") + if not isinstance(cleanup, dict) or not isinstance( + cleanup.get("requested"), bool + ): + unknown += 1 + elif cleanup["requested"] is False: + retained += 1 + elif cleanup.get("removed") is True: + disposed += 1 + else: + failed += 1 + if failed: + requested = disposed + failed + return f"CLEANUP FAILED {failed}/{requested}" + if unknown: + return f"unknown {unknown}/{len(reports)}" + if disposed and retained: + return f"disposed {disposed}/{len(reports)}; retained by request {retained}/{len(reports)}" + if disposed: + return f"disposed {disposed}/{len(reports)}" + return f"retained by request {retained}/{len(reports)}" + + +def cases_from_report(report: dict[str, Any]) -> list[dict[str, Any]]: + cases = report.get("cases") + if isinstance(cases, list): + return [case for case in cases if isinstance(case, dict)] + measurements = report.get("measurements") + derived = report.get("derived") + if isinstance(measurements, dict) and isinstance(derived, dict): + return [ + { + "passed": derived.get("passed"), + "incremental": measurements.get("incremental", {}), + "fresh_fast_full_after_change": measurements.get( + "fresh_fast_full_after_change", {} + ), + "speedup_full_rebuild_over_incremental": derived.get( + "speedup_full_rebuild_over_incremental" + ), + } + ] + return [] + + +PRE_RENAME_CONFIG_OVERRIDES = { + (entry["historical"]["key"], entry["historical"]["value"]): ( + entry["canonical"]["key"], + entry["canonical"]["value"], + ) + for entry in CONFIG_SPELLING_SPEC["config_overrides"] +} +PRE_RENAME_CONFIG_PROFILES = { + historical: details["canonical"] + for details in CONFIG_SPELLING_SPEC["profiles"].values() + for historical in details["historical"] +} +PRE_RENAME_EXPERIMENT_LABELS = { + historical: details["canonical"] + for details in CONFIG_SPELLING_SPEC["experiment_labels"].values() + for historical in details["historical"] +} + + +def canonical_config_override(key: Any, value: Any) -> tuple[str, str]: + raw = str(key), str(value) + return PRE_RENAME_CONFIG_OVERRIDES.get( + raw, + raw, + ) + + +def canonical_config_overrides(overrides: dict[Any, Any]) -> dict[str, str]: + canonical: dict[str, str] = {} + for raw_key, raw_value in overrides.items(): + key, value = canonical_config_override(raw_key, raw_value) + previous = canonical.get(key) + if previous is not None and previous != value: + raise ValueError( + f"conflicting retained config values after canonicalization: " + f"{key}={previous} and {key}={value}" + ) + canonical[key] = value + return canonical + + +def canonical_config_profile(profile: Any) -> Any: + return PRE_RENAME_CONFIG_PROFILES.get(profile, profile) + + +def canonical_experiment_label(label: str) -> str: + return PRE_RENAME_EXPERIMENT_LABELS.get(label, label) + + +def reports_use_pre_rename_config_spellings(reports: list[dict[str, Any]]) -> bool: + for report in reports: + parameters = report.get("parameters") + overrides = ( + parameters.get("config_overrides", {}) + if isinstance(parameters, dict) + else {} + ) + if not isinstance(overrides, dict): + continue + profile = ( + parameters.get("config_profile") if isinstance(parameters, dict) else None + ) + if canonical_config_profile(profile) != profile or any( + canonical_config_override(key, value) != (str(key), str(value)) + for key, value in overrides.items() + ): + return True + return False + + +def config_label(reports: list[dict[str, Any]]) -> str: + labels: set[str] = set() + for report in reports: + parameters = report.get("parameters", {}) + overrides = ( + parameters.get("config_overrides", {}) + if isinstance(parameters, dict) + else {} + ) + profile = ( + parameters.get("config_profile") if isinstance(parameters, dict) else None + ) + profile = canonical_config_profile(profile) + if isinstance(overrides, dict) and overrides: + canonical_overrides = canonical_config_overrides(overrides) + expanded = ", ".join( + f"{key}={canonical_overrides[key]}" + for key in sorted(canonical_overrides) + ) + labels.add( + f"{profile} ({expanded})" + if isinstance(profile, str) and profile + else expanded + ) + else: + labels.add( + str(profile) if isinstance(profile, str) and profile else "defaults" + ) + return " / ".join(sorted(labels)) + + +def config_signature( + reports: list[dict[str, Any]], +) -> tuple[tuple[str, str], ...] | None: + signatures: set[tuple[tuple[str, str], ...]] = set() + for report in reports: + parameters = report.get("parameters") + overrides = ( + parameters.get("config_overrides", {}) + if isinstance(parameters, dict) + else {} + ) + if not isinstance(overrides, dict): + return None + signatures.add(tuple(sorted(canonical_config_overrides(overrides).items()))) + return next(iter(signatures)) if len(signatures) == 1 else None + + +def quality_oracle_details(cases: list[dict[str, Any]]) -> list[dict[str, Any]]: + details: list[dict[str, Any]] = [] + for case_index, case in enumerate(cases, start=1): + scenario = str(case.get("scenario") or f"case {case_index}") + case_oracles = case.get("oracles") + if not isinstance(case_oracles, dict): + continue + for name, oracle in case_oracles.items(): + if name == "quality" or not isinstance(oracle, dict): + continue + quality = oracle.get("quality") + if not isinstance(quality, dict): + continue + applicable = quality.get("applicable") is not False + passed = quality.get("passed") + rank = quality.get("rank") + returned = quality.get("returned_count") + if not applicable: + result = "N/A" + elif passed is True and isinstance(rank, int) and isinstance(returned, int): + result = f"PASS (rank {rank} of {returned})" + elif passed is True: + result = "PASS" + elif isinstance(rank, int) and isinstance(returned, int): + result = f"BELOW CUTOFF (rank {rank} of {returned})" + elif isinstance(returned, int): + result = f"FAIL (not found in {returned})" + else: + result = "FAIL" + details.append( + { + "scenario": scenario, + "oracle": str(name), + "criterion": str(quality.get("criterion") or "unspecified"), + "expected": str(quality.get("expected_substring") or "n/a"), + "result": result, + "reciprocal_rank": quality.get("reciprocal_rank"), + "hit_at_1": quality.get("hit_at_1"), + "hit_at_5": quality.get("hit_at_5"), + "ndcg_at_5": quality.get("ndcg_at_5"), + "judgments": ( + f"{quality['relevance_judgments']} judgments" + if isinstance(quality.get("relevance_judgments"), int) + else "n/a" + ), + } + ) + return details + + +def compact_witness(value: Any, limit: int = 96) -> str: + if not isinstance(value, str) or not value: + return "" + single_line = " ".join(value.split()) + return single_line if len(single_line) <= limit else single_line[: limit - 1] + "…" + + +def canonical_mismatch_finding( + canonical: Any, + *, + graph_gate: Any = None, +) -> str | None: + if not isinstance(canonical, dict) or canonical.get("equal") is not False: + return None + kind = canonical.get("kind") or "canonical graph" + detail = ( + f"{kind} mismatch (incremental={canonical.get('left_count', 'n/a')}, " + f"fresh={canonical.get('right_count', 'n/a')})" + ) + witnesses = [ + compact_witness(canonical.get("left_only")), + compact_witness(canonical.get("right_only")), + ] + witnesses = [value for value in witnesses if value] + if witnesses: + detail += "; witness: " + " vs ".join(witnesses) + if ( + isinstance(graph_gate, dict) + and graph_gate.get("policy") == "declared_stale_derived_views" + and graph_gate.get("passed") is True + ): + views = graph_gate.get("declared_stale_views") + view_text = ( + ", ".join(str(value) for value in views) + if isinstance(views, list) + else "unknown" + ) + detail = f"declared stale derived views ({view_text}); " + detail + return detail + + +def correctness_findings( + cases: list[dict[str, Any]], + *, + capability_quality: bool = False, + disabled_pair_capabilities: set[str] | None = None, +) -> list[str]: + findings: list[str] = [] + disabled_pair_capabilities = disabled_pair_capabilities or set() + for case in cases: + canonical = case.get("canonical_graph") + detail = canonical_mismatch_finding( + canonical, + graph_gate=case.get("graph_gate"), + ) + if detail: + findings.append(detail) + + case_oracles = case.get("oracles") + if isinstance(case_oracles, dict): + for name, oracle in case_oracles.items(): + if not isinstance(oracle, dict) or name == "quality": + continue + quality = oracle.get("quality") + if isinstance(quality, dict) and quality.get("passed") is False: + expected = compact_witness(quality.get("expected_substring")) + rank = quality.get("rank") + cutoff = quality.get("relevance_cutoff") + if ( + capability_quality + and isinstance(rank, int) + and isinstance(cutoff, int) + ): + finding = f"{name} below quality cutoff (rank {rank}, cutoff {cutoff})" + elif capability_quality: + finding = f"{name} did not meet the quality target" + else: + finding = f"{name} failed" + if expected: + finding += f" (expected {expected})" + findings.append(finding) + + lifecycle = case.get("pair_lifecycle") + fixture = case.get("fixture") + capability = ( + str(fixture.get("capability") or "") if isinstance(fixture, dict) else "" + ) + if not isinstance(lifecycle, dict) or capability in disabled_pair_capabilities: + continue + lifecycle_canonical = lifecycle.get("canonical_graph") + if lifecycle_canonical is not canonical: + detail = canonical_mismatch_finding(lifecycle_canonical) + if detail: + findings.append(detail) + policy = lifecycle.get("incremental_policy") + immediate_expected = ( + isinstance(policy, dict) + and policy.get("immediate_freshness_expected") is True + ) + for stage, key, required in ( + ("Initial", "initial_oracles", True), + ("Post-edit", "incremental_oracles", immediate_expected), + ("Fresh", "fresh_oracles", True), + ): + oracle = lifecycle.get(key) + if ( + not required + or not isinstance(oracle, dict) + or oracle.get("passed") is not False + ): + continue + classification = oracle.get("pair_classification") + confusion = ( + classification.get("confusion") + if isinstance(classification, dict) + else None + ) + finding = f"{stage} semantic-pair quality missed the declared target" + if isinstance(confusion, dict): + tp = int(confusion.get("tp") or 0) + tn = int(confusion.get("tn") or 0) + fp = int(confusion.get("fp") or 0) + fn = int(confusion.get("fn") or 0) + finding += f": TP={tp}, TN={tn}, FP={fp}, FN={fn}" + consequences = [] + if fn: + consequences.append(f"{fn} expected positive absent") + if fp: + consequences.append(f"{fp} unexpected positive present") + if consequences: + finding += " (" + ", ".join(consequences) + ")" + findings.append(finding) + return list(dict.fromkeys(findings)) + + +def mutation_reindex_details( + cases: list[dict[str, Any]], + *, + disabled_pair_capabilities: set[str] | None = None, +) -> list[dict[str, Any]]: + """Aggregate repeated measurements without hiding the mutated source or publish route.""" + disabled_pair_capabilities = disabled_pair_capabilities or set() + grouped: dict[str, dict[str, Any]] = {} + for case_index, case in enumerate(cases, start=1): + scenario = str(case.get("scenario") or f"case {case_index}") + group = grouped.setdefault( + scenario, + { + "descriptions": set(), + "changed_paths": set(), + "routes": set(), + "reasons": set(), + "incremental_ms": [], + "work_ms": [], + "full_ms": [], + "speedups": [], + "canonical": [], + }, + ) + lifecycle = case.get("pair_lifecycle") + lifecycle = lifecycle if isinstance(lifecycle, dict) else {} + mutation = lifecycle.get("mutation", case.get("mutation")) + if isinstance(mutation, dict): + description = mutation.get("description") + if isinstance(description, str) and description: + group["descriptions"].add(description) + changed_paths = mutation.get("changed_paths") + if isinstance(changed_paths, list): + group["changed_paths"].update( + str(path) + for path in changed_paths + if isinstance(path, str) and path + ) + # Matrix artifacts predate the self-dogfood mutation object and retain + # their changed paths at the case root. Consume both schemas so an + # auditable path is never rendered as "not reported". + case_changed_paths = case.get("changed_paths") + if isinstance(case_changed_paths, list): + group["changed_paths"].update( + str(path) + for path in case_changed_paths + if isinstance(path, str) and path + ) + scenario_metadata = case.get("scenario_metadata") + if ( + not group["descriptions"] + and isinstance(scenario_metadata, dict) + and scenario_metadata.get("source") == "synthetic_inbound_frontier" + ): + language = scenario_metadata.get("cross_file_resolver_language") + if not isinstance(language, str) or not language: + language = scenario_metadata.get("language") + if isinstance(language, str) and language: + group["descriptions"].add( + f"synthetic {language} inbound-frontier definition edit" + ) + incremental = lifecycle.get("incremental_index", case.get("incremental")) + if isinstance(incremental, dict): + if isinstance(incremental.get("elapsed_ms"), (int, float)): + group["incremental_ms"].append(float(incremental["elapsed_ms"])) + if isinstance(incremental.get("indexed_work_elapsed_ms"), (int, float)): + group["work_ms"].append(float(incremental["indexed_work_elapsed_ms"])) + route = incremental.get("publish_kind") + if isinstance(route, str) and route: + group["routes"].add(route) + reason = incremental.get("exact_reason") + if isinstance(reason, str) and reason: + group["reasons"].add(reason) + full = lifecycle.get("fresh_index", case.get("fresh_fast_full_after_change")) + if isinstance(full, dict) and isinstance(full.get("elapsed_ms"), (int, float)): + group["full_ms"].append(float(full["elapsed_ms"])) + speedup = case.get("speedup_full_rebuild_over_incremental") + if isinstance(speedup, (int, float)): + group["speedups"].append(float(speedup)) + canonical = lifecycle.get("canonical_graph", case.get("canonical_graph")) + if isinstance(canonical, dict) and isinstance(canonical.get("equal"), bool): + group["canonical"].append(canonical["equal"]) + fixture = case.get("fixture") + capability = ( + str(fixture.get("capability") or "") if isinstance(fixture, dict) else "" + ) + policy = lifecycle.get("incremental_policy") + if capability in disabled_pair_capabilities: + group["canonical_policy"] = "capability disabled" + elif ( + isinstance(policy, dict) + and policy.get("immediate_freshness_expected") is False + and policy.get("policy_conformance_met") is True + and policy.get("stale_warning_present") is True + ): + group["canonical_policy"] = "deferred with warning" + + details: list[dict[str, Any]] = [] + for scenario, group in grouped.items(): + routes = sorted(group["routes"]) + reasons = sorted(group["reasons"]) + route = ", ".join(routes) if routes else "not reported" + if reasons: + route += " (" + ", ".join(reasons) + ")" + canonical = group["canonical"] + details.append( + { + "scenario": scenario, + "mutation": "; ".join(sorted(group["descriptions"])) or "not reported", + "changed_paths": ", ".join(sorted(group["changed_paths"])) + or "not reported", + "publication": route, + "incremental_p50_ms": percentile(group["incremental_ms"], 0.50), + "work_p50_ms": percentile(group["work_ms"], 0.50), + "full_p50_ms": percentile(group["full_ms"], 0.50), + "speedup_p50": ( + float(statistics.median(group["speedups"])) + if group["speedups"] + else None + ), + "canonical": group.get("canonical_policy") + or ratio(sum(canonical), len(canonical)), + } + ) + return details + + +def marker_int(lines: Any, marker: str, field: str) -> int | None: + if not isinstance(lines, list): + return None + prefix = f"{field}=" + for line in lines: + if not isinstance(line, str) or marker not in line: + continue + for item in line.split(): + if item.startswith(prefix): + try: + return int(item.split("=", 1)[1]) + except ValueError: + return None + return None + + +def dependency_observation(index_result: Any) -> tuple[int | None, int | None]: + """Read dependency cost/count from current and retained benchmark result shapes.""" + if not isinstance(index_result, dict): + return None, None + dependency = index_result.get("dependency_indexing") + phase_ms = None + packages = None + if isinstance(dependency, dict): + raw_phase = dependency.get("phase_elapsed_ms") + raw_packages = dependency.get("packages_indexed") + phase_ms = int(raw_phase) if isinstance(raw_phase, (int, float)) else None + packages = int(raw_packages) if isinstance(raw_packages, (int, float)) else None + if phase_ms is None: + phase_ms = marker_int( + index_result.get("measurement_log_markers"), "sub=dep_auto_index", "ms" + ) + response = index_result.get("response") + if packages is None and isinstance(response, dict): + raw_packages = response.get("dependencies_indexed") + packages = int(raw_packages) if isinstance(raw_packages, (int, float)) else None + return phase_ms, packages + + +def dependency_mode( + reports: list[dict[str, Any]], observed_packages: list[float] +) -> str: + support: set[bool] = set() + overrides: set[str] = set() + for report in reports: + parameters = report.get("parameters") + if not isinstance(parameters, dict): + continue + capability_support = parameters.get("capability_support") + if isinstance(capability_support, dict): + raw_support = capability_support.get( + "dependencies", capability_support.get("auto_index_deps") + ) + if isinstance(raw_support, bool): + support.add(raw_support) + config = parameters.get("config_overrides") + if isinstance(config, dict) and "auto_index_deps" in config: + overrides.add(str(config["auto_index_deps"]).lower()) + if support == {False}: + return "unsupported" + if overrides and overrides <= {"false", "0", "off"}: + return "disabled (explicit)" + if overrides and overrides <= {"true", "1", "on"}: + return "enabled (explicit)" + if observed_packages and max(observed_packages) > 0: + return "enabled (observed)" + return "unknown" + + +ALGORITHM_CAPABILITIES = ( + "rank", + "similarity", + "semantic_edges", + "git_history", + "http_links", + "dependencies", +) + + +def summarize_capability_applicability( + reports: list[dict[str, Any]], +) -> dict[str, str]: + summarized: dict[str, str] = {} + for capability in ALGORITHM_CAPABILITIES: + states: set[tuple[bool, str]] = set() + support: set[bool] = set() + for report in reports: + parameters = report.get("parameters") + capability_support = ( + parameters.get("capability_support") + if isinstance(parameters, dict) + else None + ) + if isinstance(capability_support, dict) and isinstance( + capability_support.get(capability), bool + ): + support.add(capability_support[capability]) + applicability = ( + parameters.get("capability_applicability") + if isinstance(parameters, dict) + else None + ) + state = ( + applicability.get(capability) + if isinstance(applicability, dict) + else None + ) + if isinstance(state, dict) and isinstance(state.get("applicable"), bool): + states.add( + (state["applicable"], str(state.get("reason") or "unspecified")) + ) + if support == {False}: + summarized[capability] = "unsupported by candidate" + elif len(support) > 1: + summarized[capability] = "mixed support" + elif not states: + summarized[capability] = "unknown" + elif len(states) > 1: + summarized[capability] = "mixed" + else: + applicable, reason = next(iter(states)) + summarized[capability] = "applicable" if applicable else f"N/A: {reason}" + return summarized + + +def quality_miss_is_explicit_ablation( + report: dict[str, Any], case: dict[str, Any] +) -> bool: + fixture = case.get("fixture") + capability = fixture.get("capability") if isinstance(fixture, dict) else None + parameters = report.get("parameters") + overrides = ( + parameters.get("config_overrides") if isinstance(parameters, dict) else None + ) + if not isinstance(overrides, dict): + return False + key = "rank_enabled" if capability == "rank" else "auto_index_deps" + if capability not in {"rank", "dependencies"}: + return False + value = overrides.get(key) + return value is False or (isinstance(value, str) and value.lower() == "false") + + +def semantic_pair_classification( + lifecycle: dict[str, Any], stage: str +) -> tuple[dict[str, int] | None, float | None]: + """Return one lifecycle stage's confusion matrix and F1 score.""" + oracles = lifecycle.get(f"{stage}_oracles") + pair = oracles.get("pair_classification") if isinstance(oracles, dict) else None + confusion = pair.get("confusion") if isinstance(pair, dict) else None + f1 = pair.get("f1") if isinstance(pair, dict) else None + return ( + confusion if isinstance(confusion, dict) else None, + float(f1) if isinstance(f1, (int, float)) else None, + ) + + +def semantic_pair_quality_details(cases: list[dict[str, Any]]) -> list[dict[str, Any]]: + details: list[dict[str, Any]] = [] + for case in cases: + lifecycle = case.get("pair_lifecycle") + fixture = case.get("fixture") + if not isinstance(lifecycle, dict) or not isinstance(fixture, dict): + continue + policy = lifecycle.get("incremental_policy") + policy = policy if isinstance(policy, dict) else {} + + initial_confusion, initial_f1 = semantic_pair_classification( + lifecycle, "initial" + ) + incremental_confusion, incremental_f1 = semantic_pair_classification( + lifecycle, "incremental" + ) + fresh_confusion, fresh_f1 = semantic_pair_classification(lifecycle, "fresh") + if policy.get("immediate_freshness_met") is True: + freshness = "fresh and canonical" + elif ( + policy.get("immediate_freshness_expected") is False + and policy.get("stale_warning_present") is True + ): + freshness = "deferred with warning" + else: + freshness = "unexpected stale or non-canonical" + background = case.get("background_repository") + freshness_policy = policy.get("policy") + if isinstance(freshness_policy, str): + freshness_policy = canonical_config_override( + "incremental_derived_results_refresh", freshness_policy + )[1] + details.append( + { + "capability": fixture.get("capability"), + "relationship": fixture.get("relationship"), + "task_sha256": fixture.get("task_set_sha256"), + "background_revision": ( + background.get("revision") if isinstance(background, dict) else None + ), + "background_tree": ( + background.get("tree") if isinstance(background, dict) else None + ), + "initial_confusion": initial_confusion, + "initial_f1": initial_f1, + "incremental_confusion": incremental_confusion, + "incremental_f1": incremental_f1, + "fresh_confusion": fresh_confusion, + "fresh_f1": fresh_f1, + "freshness_policy": freshness_policy, + "freshness": freshness, + "policy_conformance_met": policy.get("policy_conformance_met"), + "immediate_freshness_expected": policy.get( + "immediate_freshness_expected" + ), + "immediate_freshness_met": policy.get("immediate_freshness_met"), + } + ) + return details + + +def summarize_group(label: str, reports: list[dict[str, Any]]) -> dict[str, Any]: + canonical_label = canonical_experiment_label(label) + cases = [case for report in reports for case in cases_from_report(report)] + report_modes = {str(report.get("mode") or "") for report in reports} + capability_quality = report_modes == {"capability_quality"} + canonical: list[bool] = [] + core_graph: list[bool] = [] + for case in cases: + canonical_graph = case.get("canonical_graph") + if isinstance(canonical_graph, dict): + canonical_equal = bool(canonical_graph.get("equal")) + canonical.append(canonical_equal) + graph_gate = case.get("graph_gate") + core_graph.append( + bool(graph_gate.get("passed")) + if isinstance(graph_gate, dict) + and isinstance(graph_gate.get("passed"), bool) + else canonical_equal + ) + continue + lifecycle = case.get("pair_lifecycle") + if not isinstance(lifecycle, dict): + continue + policy = lifecycle.get("incremental_policy") + lifecycle_graph = lifecycle.get("canonical_graph") + if ( + isinstance(policy, dict) + and policy.get("immediate_freshness_expected") is True + and isinstance(lifecycle_graph, dict) + ): + lifecycle_equal = bool(lifecycle_graph.get("equal")) + canonical.append(lifecycle_equal) + core_graph.append(lifecycle_equal) + oracles: list[bool] = [] + quality_miss_ablation_states: list[bool] = [] + for report in reports: + for case in cases_from_report(report): + case_oracles = case.get("oracles") + if not isinstance(case_oracles, dict): + continue + verdict = case_oracles.get("passed") + if not isinstance(verdict, bool): + quality = case_oracles.get("quality") + verdict = quality.get("passed") if isinstance(quality, dict) else None + if isinstance(verdict, bool): + oracles.append(verdict) + if verdict is False and case.get("quality_target_met") is False: + quality_miss_ablation_states.append( + quality_miss_is_explicit_ablation(report, case) + ) + pair_quality_details = semantic_pair_quality_details(cases) + signature = config_signature(reports) + override_map = dict(signature) if signature is not None else {} + capability_config_keys = { + "similarity": "similarity_enabled", + "semantic_edges": "semantic_edges_enabled", + } + for detail in pair_quality_details: + config_key = capability_config_keys.get(str(detail.get("capability"))) + configured = override_map.get(config_key) if config_key else None + if isinstance(configured, str) and configured.lower() == "false": + detail["capability_state"] = "disabled" + detail["freshness"] = "capability disabled" + else: + detail["capability_state"] = "enabled or default" + case_passes: list[bool] = [] + for case in cases: + lifecycle = case.get("pair_lifecycle") + if isinstance(lifecycle, dict): + policy = lifecycle.get("incremental_policy") + policy = policy if isinstance(policy, dict) else {} + initial_oracles = lifecycle.get("initial_oracles") + incremental_oracles = lifecycle.get("incremental_oracles") + fresh_oracles = lifecycle.get("fresh_oracles") + passed = bool( + isinstance(initial_oracles, dict) + and initial_oracles.get("passed") + and isinstance(fresh_oracles, dict) + and fresh_oracles.get("passed") + and policy.get("policy_conformance_met") + ) + if policy.get("immediate_freshness_expected") is True: + passed = passed and bool( + isinstance(incremental_oracles, dict) + and incremental_oracles.get("passed") + ) + case_passes.append(passed) + elif capability_quality and isinstance(case.get("quality_target_met"), bool): + case_passes.append(bool(case.get("quality_target_met"))) + else: + case_passes.append(bool(case.get("passed"))) + incremental_ms: list[float] = [] + incremental_work_ms: list[float] = [] + incremental_peak_rss: list[float] = [] + initial_full_ms: list[float] = [] + full_ms: list[float] = [] + speedups: list[float] = [] + peak_rss: list[int] = [] + query_latency_ms: list[float] = [] + cold_query_latency_ms: list[float] = [] + query_response_bytes: list[float] = [] + query_response_tokens: list[float] = [] + quality_passed = 0 + quality_applicable = 0 + quality_score_weighted = 0.0 + quality_score_count = 0 + hit_at_1_weighted = 0.0 + hit_at_5_weighted = 0.0 + ndcg_weighted = 0.0 + ndcg_count = 0 + dependency_initial_ms: list[float] = [] + dependency_incremental_ms: list[float] = [] + dependency_fresh_ms: list[float] = [] + dependency_packages: list[float] = [] + for case in cases: + lifecycle = case.get("pair_lifecycle") + if isinstance(lifecycle, dict): + initial = lifecycle.get("initial_index", {}) + incremental = lifecycle.get("incremental_index", {}) + full = lifecycle.get("fresh_index", {}) + relation_oracles = [ + lifecycle.get("initial_oracles"), + lifecycle.get("incremental_oracles"), + lifecycle.get("fresh_oracles"), + ] + else: + initial = case.get("initial_fast_full", {}) + incremental = case.get("incremental", {}) + full = case.get("fresh_fast_full_after_change", {}) + relation_oracles = [] + if isinstance(initial, dict): + if isinstance(initial.get("elapsed_ms"), (int, float)): + initial_full_ms.append(float(initial["elapsed_ms"])) + if isinstance(initial.get("peak_rss_mb"), (int, float)): + peak_rss.append(int(initial["peak_rss_mb"])) + if isinstance(incremental, dict): + if isinstance(incremental.get("elapsed_ms"), (int, float)): + incremental_ms.append(float(incremental["elapsed_ms"])) + if isinstance(incremental.get("indexed_work_elapsed_ms"), (int, float)): + incremental_work_ms.append( + float(incremental["indexed_work_elapsed_ms"]) + ) + if isinstance(incremental.get("peak_rss_mb"), (int, float)): + incremental_peak_rss.append(float(incremental["peak_rss_mb"])) + peak_rss.append(int(incremental["peak_rss_mb"])) + if isinstance(full, dict): + if isinstance(full.get("elapsed_ms"), (int, float)): + full_ms.append(float(full["elapsed_ms"])) + if isinstance(full.get("peak_rss_mb"), int): + peak_rss.append(full["peak_rss_mb"]) + if isinstance(case.get("speedup_full_rebuild_over_incremental"), (int, float)): + speedups.append(float(case["speedup_full_rebuild_over_incremental"])) + elif ( + ( + not isinstance(lifecycle, dict) + or isinstance(lifecycle.get("incremental_policy"), dict) + and lifecycle["incremental_policy"].get("immediate_freshness_met") + is True + ) + and isinstance(full, dict) + and isinstance(full.get("elapsed_ms"), (int, float)) + and isinstance(incremental, dict) + and isinstance(incremental.get("elapsed_ms"), (int, float)) + and float(incremental["elapsed_ms"]) > 0 + ): + speedups.append( + float(full["elapsed_ms"]) / float(incremental["elapsed_ms"]) + ) + for index_result, timings in ( + (initial, dependency_initial_ms), + (incremental, dependency_incremental_ms), + (full, dependency_fresh_ms), + ): + phase_ms, packages = dependency_observation(index_result) + if phase_ms is not None: + timings.append(float(phase_ms)) + if packages is not None: + dependency_packages.append(float(packages)) + case_oracles = case.get("oracles", {}) + if isinstance(case_oracles, dict) and not isinstance(lifecycle, dict): + quality = case_oracles.get("quality", {}) + if isinstance(quality, dict): + applicable = int(quality.get("applicable_count") or 0) + quality_passed += int(quality.get("passed_count") or 0) + quality_applicable += applicable + score = quality.get("score") + hit_at_1 = quality.get("hit_at_1") + hit_at_5 = quality.get("hit_at_5") + mean_ndcg_at_5 = quality.get("mean_ndcg_at_5") + ndcg_applicable = int(quality.get("ndcg_applicable_count") or 0) + if applicable and isinstance(score, (int, float)): + quality_score_weighted += float(score) * applicable + quality_score_count += applicable + if applicable and isinstance(hit_at_1, (int, float)): + hit_at_1_weighted += float(hit_at_1) * applicable + if applicable and isinstance(hit_at_5, (int, float)): + hit_at_5_weighted += float(hit_at_5) * applicable + if ndcg_applicable and isinstance(mean_ndcg_at_5, (int, float)): + ndcg_weighted += float(mean_ndcg_at_5) * ndcg_applicable + ndcg_count += ndcg_applicable + for oracle in case_oracles.values(): + if not isinstance(oracle, dict): + continue + if isinstance(oracle.get("elapsed_ms"), (int, float)): + cold_query_latency_ms.append(float(oracle["elapsed_ms"])) + repeated_elapsed = repeated_query_elapsed_ms(oracle) + if repeated_elapsed is not None: + query_latency_ms.append(repeated_elapsed) + if isinstance(oracle.get("response_bytes"), (int, float)): + query_response_bytes.append(float(oracle["response_bytes"])) + if isinstance(oracle.get("response_token_estimate"), (int, float)): + query_response_tokens.append( + float(oracle["response_token_estimate"]) + ) + for relation in relation_oracles: + response_quality = ( + relation.get("response_quality") if isinstance(relation, dict) else None + ) + if not isinstance(response_quality, dict): + continue + if isinstance(response_quality.get("elapsed_ms"), (int, float)): + cold_query_latency_ms.append(float(response_quality["elapsed_ms"])) + repeated_elapsed = repeated_query_elapsed_ms(response_quality) + if repeated_elapsed is not None: + query_latency_ms.append(repeated_elapsed) + if isinstance(response_quality.get("response_bytes"), (int, float)): + query_response_bytes.append(float(response_quality["response_bytes"])) + if isinstance( + response_quality.get("response_token_estimate"), (int, float) + ): + query_response_tokens.append( + float(response_quality["response_token_estimate"]) + ) + + canonical_failed = any(not value for value in core_graph) + oracle_target_missed = any(not value for value in oracles) + required_pair_stage_missed = False + for case in cases: + lifecycle = case.get("pair_lifecycle") + if not isinstance(lifecycle, dict): + continue + policy = lifecycle.get("incremental_policy") + immediate_expected = ( + isinstance(policy, dict) + and policy.get("immediate_freshness_expected") is True + ) + required = [lifecycle.get("initial_oracles"), lifecycle.get("fresh_oracles")] + if immediate_expected: + required.append(lifecycle.get("incremental_oracles")) + if any( + isinstance(oracle, dict) and oracle.get("passed") is False + for oracle in required + ): + required_pair_stage_missed = True + break + quality_target_missed = oracle_target_missed or required_pair_stage_missed + result_oracle_failed = any( + isinstance((case_oracles := case.get("oracles")), dict) + and any( + isinstance(oracle, dict) + and isinstance((quality := oracle.get("quality")), dict) + and quality.get("passed") is False + for name, oracle in case_oracles.items() + if name != "quality" + ) + for case in cases + ) + missed_oracle_count = sum(1 for value in oracles if not value) + explicit_ablation_miss = ( + bool(quality_miss_ablation_states) + and len(quality_miss_ablation_states) == missed_oracle_count + and all(quality_miss_ablation_states) + ) + deferred_freshness = any( + detail["freshness"] == "deferred with warning" + for detail in pair_quality_details + ) + declared_stale_views = any( + isinstance((gate := case.get("graph_gate")), dict) + and gate.get("policy") == "declared_stale_derived_views" + and gate.get("passed") is True + and not ( + isinstance((canonical_graph := case.get("canonical_graph")), dict) + and canonical_graph.get("equal") is True + ) + for case in cases + ) + freshness_policy_failed = any( + detail.get("policy_conformance_met") is False + for detail in pair_quality_details + if detail.get("capability_state") != "disabled" + ) + cleanup_failed = any( + isinstance((cleanup := report.get("cleanup")), dict) + and cleanup.get("requested") is True + and cleanup.get("removed") is not True + for report in reports + ) + if canonical_failed: + decision = "REJECT: graph correctness" + elif freshness_policy_failed: + decision = "REJECT: freshness policy" + elif cleanup_failed: + decision = "REJECT: lifecycle cleanup" + elif quality_target_missed and (capability_quality or explicit_ablation_miss): + decision = "BELOW QUALITY TARGET" + elif quality_target_missed: + decision = "REJECT: task correctness" + elif case_passes and not all(case_passes) and not capability_quality: + decision = "REJECT: benchmark gate" + elif not cases: + decision = "REJECT: no cases" + elif declared_stale_views: + decision = "PASS: DECLARED STALE VIEWS" + elif deferred_freshness: + decision = "PASS: DEFERRED FRESHNESS" + else: + decision = "PASS" + + hashes = sorted( + { + str(report.get("binary_metadata", {}).get("sha256", "")) + for report in reports + if isinstance(report.get("binary_metadata"), dict) + and report.get("binary_metadata", {}).get("sha256") + } + ) + pair_f1_values = [ + value + for detail in pair_quality_details + for value in (detail.get("initial_f1"), detail.get("fresh_f1")) + if isinstance(value, (int, float)) + ] + retrieval_score = ( + quality_score_weighted / quality_score_count + if quality_score_count + else quality_passed / quality_applicable + if quality_applicable + else None + ) + pair_f1_score = statistics.mean(pair_f1_values) if pair_f1_values else None + graph_fidelity_score = sum(canonical) / len(canonical) if canonical else None + core_graph_fidelity_score = ( + sum(core_graph) / len(core_graph) if core_graph else None + ) + task_success_score = ( + quality_passed / quality_applicable + if quality_applicable + else sum(oracles) / len(oracles) + if oracles + else None + ) + result_quality_values = [ + value + for value in (retrieval_score, pair_f1_score) + if isinstance(value, (int, float)) + ] + result_quality_score = ( + statistics.mean(result_quality_values) if result_quality_values else None + ) + quality_categories = ( + result_quality_score, + graph_fidelity_score, + task_success_score, + ) + overall_quality_score = ( + math.prod(quality_categories) ** (1.0 / len(quality_categories)) + if all(isinstance(value, (int, float)) for value in quality_categories) + else None + ) + scenarios = {str(case.get("scenario")) for case in cases if case.get("scenario")} + workload_backgrounds: set[str] = set() + workload_tasks: set[str] = set() + for report in reports: + parameters = report.get("parameters") + if isinstance(parameters, dict): + for key in ("repository_background", "quality_background"): + background = parameters.get(key) + if isinstance(background, dict): + workload_backgrounds.add( + json.dumps(background, separators=(",", ":"), sort_keys=True) + ) + for case in cases: + background = case.get("background_repository") + if isinstance(background, dict): + workload_backgrounds.add( + json.dumps(background, separators=(",", ":"), sort_keys=True) + ) + fixture = case.get("fixture") + if isinstance(fixture, dict) and fixture.get("task_set_sha256"): + workload_tasks.add(str(fixture["task_set_sha256"])) + contracts = { + str(gate.get("contract")) + for case in cases + if isinstance((gate := case.get("frontier_coverage_gate")), dict) + and gate.get("contract") + } + frontier_files = { + parameters.get("frontier_files") + for report in reports + if isinstance((parameters := report.get("parameters")), dict) + and isinstance(parameters.get("frontier_files"), int) + } + exact_caps: set[int] = set() + index_modes = { + str(parameters.get("index_mode")) + for report in reports + if isinstance((parameters := report.get("parameters")), dict) + and parameters.get("index_mode") + } + execution_orders = { + str(parameters.get("execution_order") or "grouped") + for report in reports + if isinstance((parameters := report.get("parameters")), dict) + } + for report in reports: + parameters = report.get("parameters") + if not isinstance(parameters, dict): + continue + overrides = parameters.get("config_overrides") + if not isinstance(overrides, dict): + continue + raw_cap = overrides.get("incremental_exact_max_affected_paths") + with suppress(TypeError, ValueError): + exact_caps.add(int(raw_cap)) + full_values = full_ms or initial_full_ms + disabled_pair_capabilities = { + str(detail.get("capability")) + for detail in pair_quality_details + if detail.get("capability_state") == "disabled" + } + findings = correctness_findings( + cases, + capability_quality=capability_quality, + disabled_pair_capabilities=disabled_pair_capabilities, + ) + if freshness_policy_failed: + findings.append( + "The incremental semantic result did not conform to the recorded freshness policy; " + "the post-edit pair result and whole-graph canonical comparison must be interpreted " + "together." + ) + disabled_pair_controls = [ + detail + for detail in pair_quality_details + if detail.get("capability_state") == "disabled" + ] + if disabled_pair_controls: + findings.append( + "The explicit capability-off control omitted the judged positive in initial, " + "post-edit, and fresh results; this is the expected ablation contrast, not a " + "freshness deferral or execution failure." + ) + if deferred_freshness: + findings.insert( + 0, + "Immediate semantic freshness was intentionally deferred under the recorded policy; " + "the structured stale warning was present and initial/fresh pair tasks passed", + ) + return { + "candidate": canonical_label, + "decision": decision, + "graph_error": ( + "**GRAPH ERROR**" + if canonical_failed + else "**FRESHNESS ERROR**" + if freshness_policy_failed + else "none" + ), + "result_error": ( + "**QUALITY TARGET MISS**" + if quality_target_missed and (capability_quality or explicit_ablation_miss) + else "**RESULT ERROR**" + if quality_target_missed or result_oracle_failed + else "none" + ), + "run_error": ( + "**CLEANUP ERROR**" + if cleanup_failed + else "**PROCESSING ERROR**" + if case_passes + and not all(case_passes) + and not canonical_failed + and not (quality_target_missed or result_oracle_failed) + else "**EVIDENCE ERROR**" + if not cases + else "none" + ), + "cases": ratio(sum(case_passes), len(case_passes)), + "canonical": ratio(sum(canonical), len(canonical)), + "core_graph": ratio(sum(core_graph), len(core_graph)), + "oracles": ratio(sum(oracles), len(oracles)), + "quality_score": retrieval_score, + "pair_f1_score": pair_f1_score, + "overall_quality_score": overall_quality_score, + "graph_fidelity_score": graph_fidelity_score, + "core_graph_fidelity_score": core_graph_fidelity_score, + "task_success_score": task_success_score, + "hit_at_1": hit_at_1_weighted / quality_score_count + if quality_score_count + else None, + "hit_at_5": hit_at_5_weighted / quality_score_count + if quality_score_count + else None, + "ndcg_at_5": ndcg_weighted / ndcg_count if ndcg_count else None, + "quality_checks": ratio(quality_passed, quality_applicable), + "query_response_p50_bytes": percentile(query_response_bytes, 0.50), + "query_response_p50_tokens": percentile(query_response_tokens, 0.50), + "query_latency_p50_ms": percentile(query_latency_ms, 0.50), + "cold_query_latency_p50_ms": percentile(cold_query_latency_ms, 0.50), + "query_observations": len(query_latency_ms), + "query_range_ms": (min(query_latency_ms), max(query_latency_ms)) + if query_latency_ms + else None, + "incremental_observations": len(incremental_ms), + "incremental_range_ms": (min(incremental_ms), max(incremental_ms)) + if incremental_ms + else None, + "full_observations": len(full_values), + "full_range_ms": (min(full_values), max(full_values)) if full_values else None, + "capabilities": config_label(reports), + "capability_signature": config_signature(reports), + "pre_rename_config_spellings": ( + canonical_label != label or reports_use_pre_rename_config_spellings(reports) + ), + "incremental_p50_ms": percentile(incremental_ms, 0.50), + "incremental_work_p50_ms": percentile(incremental_work_ms, 0.50), + "incremental_peak_p50_mb": percentile(incremental_peak_rss, 0.50), + "incremental_p95_ms": percentile(incremental_ms, 0.95), + "full_p50_ms": percentile(full_values, 0.50), + "speedup_p50": float(statistics.median(speedups)) if speedups else None, + "peak_rss_mb": max(peak_rss) if peak_rss else None, + "dependency_mode": dependency_mode(reports, dependency_packages), + "dependency_packages_p50": percentile(dependency_packages, 0.50), + "dependency_initial_p50_ms": percentile(dependency_initial_ms, 0.50), + "dependency_incremental_p50_ms": percentile(dependency_incremental_ms, 0.50), + "dependency_fresh_p50_ms": percentile(dependency_fresh_ms, 0.50), + "index_modes": ", ".join(sorted(index_modes)) if index_modes else "unknown", + "execution_orders": ", ".join(sorted(execution_orders)) + if execution_orders + else "unknown", + "capability_applicability": summarize_capability_applicability(reports), + "lifecycle": evidence_lifecycle(reports), + "binary_sha256": ", ".join(value[:12] for value in hashes) or "n/a", + "findings": findings, + "quality_details": quality_oracle_details(cases), + "pair_quality_details": pair_quality_details, + "mutation_details": mutation_reindex_details( + cases, + disabled_pair_capabilities=disabled_pair_capabilities, + ), + "scenario": next(iter(scenarios)) if len(scenarios) == 1 else None, + "pareto_workload": json.dumps( + { + "backgrounds": sorted(workload_backgrounds), + "index_modes": sorted(index_modes), + "report_modes": sorted(report_modes), + "scenarios": sorted(scenarios), + "task_sets": sorted(workload_tasks), + }, + separators=(",", ":"), + sort_keys=True, + ), + "frontier_files": next(iter(frontier_files)) + if len(frontier_files) == 1 + else None, + "exact_cap": next(iter(exact_caps)) if len(exact_caps) == 1 else None, + "frontier_contract": next(iter(contracts)) if len(contracts) == 1 else None, + "pareto": "unclassified", + "pareto_reason": "not evaluated", + } + + +def historical_speedup( + baseline: dict[str, Any], + latest: dict[str, Any], + metric: str, + *, + comparable: bool, +) -> float | None: + """Return a ratio only when the two quality-gated rows are comparable.""" + if not comparable: + return None + old = baseline.get(metric) + new = latest.get(metric) + return ( + old / new + if isinstance(old, (int, float)) and isinstance(new, (int, float)) and new > 0 + else None + ) + + +def historical_delta_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + latest_by_signature = { + row.get("capability_signature"): row + for row in rows + if str(row.get("candidate", "")).startswith("latest-") + and row.get("capability_signature") is not None + } + comparisons: list[dict[str, Any]] = [] + for baseline in rows: + if str(baseline.get("candidate", "")).startswith("latest-"): + continue + latest = latest_by_signature.get(baseline.get("capability_signature")) + if latest is None: + continue + + accepted_decisions = { + "PASS", + "PASS: DEFERRED FRESHNESS", + "PASS: DECLARED STALE VIEWS", + } + baseline_decision = baseline.get("decision") + latest_decision = latest.get("decision") + if ( + baseline_decision not in accepted_decisions + or latest_decision not in accepted_decisions + ): + comparison_status = "not comparable: correctness/quality gate" + elif baseline_decision != latest_decision: + comparison_status = "not comparable: freshness/quality decision differs" + else: + quality_axes = ( + "overall_quality_score", + "pair_f1_score", + "graph_fidelity_score", + "task_success_score", + ) + quality_matches = all( + (baseline.get(axis) is None and latest.get(axis) is None) + or ( + isinstance(baseline.get(axis), (int, float)) + and isinstance(latest.get(axis), (int, float)) + and math.isclose( + float(baseline[axis]), + float(latest[axis]), + rel_tol=1e-9, + abs_tol=1e-12, + ) + ) + for axis in quality_axes + ) + if not quality_matches: + comparison_status = "not comparable: measured quality differs" + else: + minimum_observations = min( + int(baseline.get(key) or 0) + for key in ( + "incremental_observations", + "full_observations", + "query_observations", + ) + ) + minimum_observations = min( + minimum_observations, + *( + int(latest.get(key) or 0) + for key in ( + "incremental_observations", + "full_observations", + "query_observations", + ) + ), + ) + comparison_status = ( + "quality-matched repeated evidence" + if minimum_observations >= 3 + else f"descriptive only: minimum matched observation count {minimum_observations}" + ) + comparable = not comparison_status.startswith("not comparable") + + comparisons.append( + { + "latest": latest["candidate"], + "baseline": baseline["candidate"], + "incremental_speedup": historical_speedup( + baseline, latest, "incremental_p50_ms", comparable=comparable + ), + "full_speedup": historical_speedup( + baseline, latest, "full_p50_ms", comparable=comparable + ), + "query_speedup": historical_speedup( + baseline, latest, "query_latency_p50_ms", comparable=comparable + ), + "latest_quality": latest.get("overall_quality_score"), + "baseline_quality": baseline.get("overall_quality_score"), + "baseline_decision": baseline.get("decision"), + "comparison_status": comparison_status, + } + ) + return comparisons + + +def frontier_crossover_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Pair the closest configured fallback and exact run for each frontier.""" + grouped: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list) + for row in rows: + scenario = row.get("scenario") + frontier_files = row.get("frontier_files") + if isinstance(scenario, str) and isinstance(frontier_files, int): + grouped[(scenario, frontier_files)].append(row) + + crossovers: list[dict[str, Any]] = [] + for (scenario, frontier_files), candidates in sorted(grouped.items()): + fallbacks = [ + row + for row in candidates + if row.get("frontier_contract") == "configured_cap_fallback" + and isinstance(row.get("exact_cap"), int) + ] + exact = [ + row + for row in candidates + if row.get("frontier_contract") == "exact_frontier" + and isinstance(row.get("exact_cap"), int) + ] + if not fallbacks or not exact: + continue + fallback = max(fallbacks, key=lambda row: row["exact_cap"]) + exact_run = min(exact, key=lambda row: row["exact_cap"]) + fallback_elapsed = fallback.get("incremental_p50_ms") + exact_elapsed = exact_run.get("incremental_p50_ms") + ratio_value = ( + exact_elapsed / fallback_elapsed + if isinstance(exact_elapsed, (int, float)) + and isinstance(fallback_elapsed, (int, float)) + and fallback_elapsed > 0 + else None + ) + if ratio_value is None: + conclusion = "not measured" + elif ratio_value < 0.95: + conclusion = "exact faster" + elif ratio_value <= 1.05: + conclusion = "tied" + else: + conclusion = "fallback faster" + crossovers.append( + { + "scenario": scenario, + "affected_files": frontier_files + 1, + "fallback_cap": fallback["exact_cap"], + "fallback_p50_ms": fallback_elapsed, + "fallback_work_p50_ms": fallback.get("incremental_work_p50_ms"), + "fallback_rss_p50_mb": fallback.get("incremental_peak_p50_mb"), + "exact_cap": exact_run["exact_cap"], + "exact_p50_ms": exact_elapsed, + "exact_work_p50_ms": exact_run.get("incremental_work_p50_ms"), + "exact_rss_p50_mb": exact_run.get("incremental_peak_p50_mb"), + "full_p50_ms": exact_run.get("full_p50_ms"), + "exact_fallback_ratio": ratio_value, + "conclusion": conclusion, + } + ) + return crossovers + + +PARETO_MINIMIZE = ( + "incremental_p50_ms", + "query_latency_p50_ms", + "query_response_p50_tokens", + "peak_rss_mb", +) + + +def dominates(left: dict[str, Any], right: dict[str, Any]) -> bool: + left_quality = left.get("overall_quality_score") + right_quality = right.get("overall_quality_score") + if not isinstance(left_quality, (int, float)) or not isinstance( + right_quality, (int, float) + ): + return False + left_values = [left.get(key) for key in PARETO_MINIMIZE] + right_values = [right.get(key) for key in PARETO_MINIMIZE] + if not all(isinstance(value, (int, float)) for value in left_values + right_values): + return False + no_worse = left_quality >= right_quality and all( + left_value <= right_value + for left_value, right_value in zip(left_values, right_values, strict=True) + ) + strictly_better = left_quality > right_quality or any( + left_value < right_value + for left_value, right_value in zip(left_values, right_values, strict=True) + ) + return no_worse and strictly_better + + +def mark_pareto_frontier(rows: list[dict[str, Any]]) -> None: + """Mark correctness-admissible, fully measured non-dominated candidates.""" + eligible = [ + row + for row in rows + if row.get("decision") == "PASS" + and isinstance(row.get("overall_quality_score"), (int, float)) + and all(isinstance(row.get(key), (int, float)) for key in PARETO_MINIMIZE) + ] + for row in rows: + row["pareto"] = "ineligible" + missing = [ + key + for key in ("overall_quality_score", *PARETO_MINIMIZE) + if not isinstance(row.get(key), (int, float)) + ] + reasons = [] + if row.get("decision") != "PASS": + reasons.append(str(row.get("decision"))) + if missing: + reasons.append("missing " + ", ".join(missing)) + row["pareto_reason"] = "; ".join(reasons) or "not eligible" + for row in eligible: + dominators = [ + other + for other in eligible + if other is not row + and other.get("pareto_workload") == row.get("pareto_workload") + and dominates(other, row) + ] + if dominators: + dominator = dominators[0] + row["pareto"] = f"dominated by {dominator['candidate']}" + row["pareto_reason"] = ( + f"{dominator['candidate']} has overall quality " + f"{dominator['overall_quality_score']:.3f} >= " + f"{row['overall_quality_score']:.3f} and is no slower/larger on every cost axis" + ) + else: + row["pareto"] = "frontier" + row["pareto_reason"] = ( + "within the same workload, no passing, fully measured candidate is at least as " + "good on overall quality and every cost axis while being strictly better on one " + "or more axes" + ) + + +def display(value: Any, digits: int = 1) -> str: + if value is None: + return "n/a" + if isinstance(value, float): + return f"{value:.{digits}f}" + return str(value).replace("|", "\\|") + + +def display_range(value: Any) -> str: + if not isinstance(value, tuple) or len(value) != 2: + return "n/a" + return f"[{display(value[0])}, {display(value[1])}]" + + +def display_confusion(value: Any) -> str: + if not isinstance(value, dict): + return "n/a" + return "/".join(str(value.get(key, "n/a")) for key in ("tp", "tn", "fp", "fn")) + + +def atomic_write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + try: + with temporary.open("w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + +def render_search_projection(document: dict[str, Any]) -> str: + if document.get("mode") != "search_projection": + raise ValueError("expected a search_projection result document") + observations = document.get("observations") + derived = document.get("derived") + if not isinstance(observations, list) or not isinstance(derived, dict): + raise ValueError( + "search-projection result is missing observations or derived data" + ) + completion = document.get("completion") + status = ( + str(completion.get("status")) if isinstance(completion, dict) else "unknown" + ) + labels = { + "compact_default": "compact default", + "compact_true": "compact true", + "compact_selected_fields": "compact + selected fields", + "compact_false": "non-compact", + } + lines = [ + "## search_graph JSON projection", + "", + f"Outcome: {status} — ranked-result identity parity=" + f"{str(bool(derived.get('identity_parity'))).lower()}, internal fields absent=" + f"{str(bool(derived.get('internal_fields_absent'))).lower()}.", + "", + "| Variant | Results | Ranked identities | Property fields | Payload bytes | " + "Estimated tokens* | Call ms† | Post-call RSS MiB‡ | Transport | Cleanup |", + "|---|---:|---|---|---:|---:|---:|---:|---|---|", + ] + non_compact_fields: list[str] = [] + for item in observations: + if not isinstance(item, dict): + continue + fields = item.get("property_fields") + typed_fields = list(map(str, fields)) if isinstance(fields, list) else [] + fields_text = ( + f"{len(typed_fields)} fields" + if len(typed_fields) > 4 + else (", ".join(typed_fields) if typed_fields else "none") + ) + if item.get("variant") == "compact_false": + non_compact_fields = typed_fields + rss_kb = item.get("post_call_rss_kb") + lines.append( + "| " + + " | ".join( + ( + labels.get(str(item.get("variant")), str(item.get("variant"))), + f"{int(item['returned_count']):,}", + "Equal" if item.get("identity_equal_to_default") else "Different", + fields_text, + f"{int(item['response_bytes']):,}", + f"{int(item['response_token_estimate']):,}", + f"{float(item['elapsed_ms']):.3f}", + f"{float(rss_kb) / 1024:.1f}" + if isinstance(rss_kb, (int, float)) + else "n/a", + "Survived" if item.get("transport_survived") else "Interrupted", + "Reaped" if item.get("server_reaped") else "Incomplete", + ) + ) + + " |" + ) + compact_bytes = derived.get("compact_bytes") + verbose_bytes = derived.get("non_compact_bytes") + savings = ( + 100.0 * (1.0 - float(compact_bytes) / float(verbose_bytes)) + if isinstance(compact_bytes, (int, float)) + and isinstance(verbose_bytes, (int, float)) + and verbose_bytes + else None + ) + binary = document.get("binary_metadata") + sha = binary.get("sha256") if isinstance(binary, dict) else None + cleanup = document.get("cleanup") + cleanup_removed = cleanup.get("removed") if isinstance(cleanup, dict) else None + lines.extend( + ( + "", + "### Interpretation and audit boundary", + "", + ( + "- Non-compact property fields: " + ", ".join(non_compact_fields) + "." + if non_compact_fields + else "- Non-compact property fields: none." + ), + f"- Compact output uses {savings:.1f}% fewer payload bytes than non-compact output." + if savings is not None + else "- Compact versus non-compact byte savings were not measured.", + "- No fp, sp, or bt indexing fields appear in any variant." + if derived.get("internal_fields_absent") + else "- One or more internal indexing fields were observed.", + f"- Claim boundary: {derived.get('claim_boundary', 'projection-only comparison')}", + f"- Run ID: {document.get('run_id', 'n/a')}; binary SHA-256: {sha or 'n/a'}.", + f"- Auto-created fixture cleanup confirmed: {str(cleanup_removed).lower()}.", + "", + "* Tokens are the deterministic ceil(UTF-8 payload bytes / 4) estimate, not a " + "model-tokenizer count.", + "", + "† There is one observation per variant. The table is a response-projection and " + "ranked-identity check, not a latency comparison.", + "", + "‡ RSS is sampled after each call and is not peak RSS.", + ) + ) + return "\n".join(lines) + "\n" + + +def render_list_projects_scaling(document: dict[str, Any]) -> str: + if document.get("mode") != "list_projects_scaling": + raise ValueError("expected a list_projects_scaling result document") + observations = document.get("observations") + derived = document.get("derived") + if not isinstance(observations, list) or not isinstance(derived, dict): + raise ValueError( + "list-project scaling result is missing observations or derived data" + ) + completion = document.get("completion") + completion_status = ( + str(completion.get("status")) if isinstance(completion, dict) else "unknown" + ) + all_valid = bool(derived.get("passed")) + outcome_detail = ( + "all requested inventories returned, follow-up MCP requests succeeded, and server " + "resources were reaped" + if all_valid + else "one or more inventory, transport, or teardown checks were incomplete" + ) + lines = [ + "## `list_projects` response scaling", + "", + f"Outcome: {completion_status} — {outcome_detail}.", + "", + "| Requested projects | Returned projects | Payload bytes | Estimated tokens* | " + "Call ms† | Post-call RSS MiB‡ | Transport | Server cleanup | Fixture DB MiB |", + "|---:|---:|---:|---:|---:|---:|---|---|---:|", + ] + for item in observations: + if not isinstance(item, dict): + continue + rss_kb = item.get("post_call_rss_kb") + fixture_bytes = item.get("fixture_db_bytes") + lines.append( + "| " + + " | ".join( + ( + f"{int(item['requested_projects']):,}", + f"{int(item['returned_projects']):,}", + f"{int(item['response_bytes']):,}", + f"{int(item['response_token_estimate']):,}", + f"{float(item['elapsed_ms']):.3f}", + f"{float(rss_kb) / 1024:.1f}" + if isinstance(rss_kb, (int, float)) + else "n/a", + "Survived" if item.get("transport_survived") else "Interrupted", + "Reaped" if item.get("server_reaped") else "Incomplete", + ( + f"{float(fixture_bytes) / (1024 * 1024):.1f}" + if isinstance(fixture_bytes, (int, float)) + else "n/a" + ), + ) + ) + + " |" + ) + growth = derived.get("incremental_response_bytes_per_project") + claim_boundary = derived.get("claim_boundary") + binary = document.get("binary_metadata") + sha = binary.get("sha256") if isinstance(binary, dict) else None + cleanup = document.get("cleanup") + cleanup_removed = cleanup.get("removed") if isinstance(cleanup, dict) else None + lines.extend( + ( + "", + "### Interpretation and audit boundary", + "", + f"- Observed payload growth: {float(growth):.1f} bytes per added project." + if isinstance(growth, (int, float)) + else "- Observed payload growth: not measured.", + f"- Claim boundary: {claim_boundary}" + if isinstance(claim_boundary, str) + else "- Claim boundary: this measures `list_projects` alone.", + f"- Run ID: `{document.get('run_id', 'n/a')}`; binary SHA-256: `{sha or 'n/a'}`.", + f"- Auto-created fixture cleanup confirmed: {str(cleanup_removed).lower()}.", + "", + "* Tokens are the deterministic `ceil(UTF-8 payload bytes / 4)` estimate, not a " + "model-tokenizer count.", + "", + "† This pilot has one observation per project count. Latency is descriptive and must " + "not be presented as a population estimate or regression threshold.", + "", + "‡ RSS is sampled after each call and is not peak RSS. Fixture DB size is transient " + "isolated-test storage, not response memory or a recommended cache size.", + ) + ) + return "\n".join(lines) + "\n" + + +def render_mcp_surface_parity(document: dict[str, Any]) -> str: + if document.get("mode") != "mcp_surface_parity": + raise ValueError("expected an mcp_surface_parity result document") + surfaces = document.get("surfaces") + comparison = document.get("comparison") + if not isinstance(surfaces, dict) or not isinstance(comparison, dict): + raise ValueError("MCP surface result is missing surfaces or comparison") + + classic = surfaces.get("classic") + pre = surfaces.get("streamlined_pre_reveal") + post = surfaces.get("streamlined_post_reveal") + pre_comparison = comparison.get("pre_reveal") + post_comparison = comparison.get("post_reveal") + if not all( + isinstance(value, dict) + for value in (classic, pre, post, pre_comparison, post_comparison) + ): + raise ValueError("MCP surface result is missing one or more parity states") + + assert isinstance(classic, dict) + assert isinstance(pre, dict) + assert isinstance(post, dict) + assert isinstance(pre_comparison, dict) + assert isinstance(post_comparison, dict) + capability_parity = comparison.get("capability_parity") + if not isinstance(capability_parity, list): + capability_parity = [] + classic_count = classic.get("tool_count") + post_classic = ( + f"{classic_count}/{classic_count}" + if post_comparison.get("classic_name_parity") and isinstance(classic_count, int) + else "incomplete" + ) + rows = ( + ( + "Pure classic", + classic, + f"{classic_count}/{classic_count}" + if isinstance(classic_count, int) + else "n/a", + "n/a (advertised directly)", + ), + ( + "Streamlined before reveal", + pre, + pre_comparison.get("advertised_classic_tools"), + pre_comparison.get("dispatch_recognized_classic_tools"), + ), + ( + "Same streamlined process after reveal", + post, + post_classic, + "n/a (advertised after reveal)", + ), + ) + lines = [ + "## MCP tool-surface parity", + "", + "These are three separate discovery states. The post-reveal row comes from the same " + "streamlined server process as the pre-reveal row.", + "", + "### Capability outcomes", + "", + "| Capability outcome | Classic advertised | Streamlined before reveal | " + "Streamlined after reveal | Evidence boundary |", + "|---|---|---|---|---|", + ] + for item in capability_parity: + if not isinstance(item, dict): + continue + pre_state = ( + "advertised" + if item.get("streamlined_pre_reveal_advertised") + else "callable but hidden" + if item.get("streamlined_pre_reveal_callable") + else "not demonstrated" + ) + lines.append( + "| " + + " | ".join( + ( + str(item.get("outcome") or item.get("capability") or "unknown"), + "yes" if item.get("classic_advertised") else "no", + pre_state, + "advertised" + if item.get("streamlined_post_reveal_advertised") + else "not demonstrated", + str(item.get("evidence") or "surface evidence only"), + ) + ) + + " |" + ) + lines.extend( + [ + "", + "### Discovery and response cost", + "", + "| State | Advertised tools | Advertised classic names | Classic handlers recognized* | " + "tools/list bytes | Estimated tokens† | tools/list ms‡ |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + ) + for label, surface, advertised_classic, dispatch in rows: + lines.append( + "| " + + " | ".join( + ( + label, + display(surface.get("tool_count")), + display(advertised_classic), + display(dispatch), + display(surface.get("response_bytes")), + display(surface.get("response_token_estimate")), + display(surface.get("list_elapsed_ms"), 3), + ) + ) + + " |" + ) + + hidden = pre_comparison.get("intentionally_hidden_classic_tools") + alias = pre_comparison.get("get_code_alias") + hidden_count = len(hidden) if isinstance(hidden, list) else "unknown" + alias_text = "not measured" + if isinstance(alias, dict): + alias_text = ( + f"property names equal={str(bool(alias.get('property_names_equal'))).lower()}, " + f"required names equal={str(bool(alias.get('required_names_equal'))).lower()}, " + f"validation shape equal={str(bool(alias.get('validation_shape_equal'))).lower()}, " + f"complete advertised schema identical={str(bool(alias.get('schema_equal'))).lower()}" + ) + lines.extend( + ( + "", + "### Parity checks", + "", + f"- Pre-reveal intentionally hidden classic names: {hidden_count}.", + f"- Post-reveal classic name parity: {str(bool(post_comparison.get('classic_name_parity'))).lower()}.", + f"- Post-reveal classic input-schema parity: {str(bool(post_comparison.get('classic_schema_parity'))).lower()}.", + f"- Post-reveal full MCP contract parity: " + f"{str(bool(post_comparison.get('classic_contract_parity'))).lower()}.", + f"- `notifications/tools/list_changed` observed after reveal: " + f"{str(bool(post_comparison.get('tools_list_changed_observed'))).lower()}.", + f"- MCP processes and reader threads reaped: " + f"{str(bool(comparison.get('lifecycle_passed'))).lower()}.", + f"- `get_code` versus classic `get_code_snippet`: {alias_text}.", + "", + "\\* Handler recognition uses bounded empty-argument `tools/call` requests and only proves " + "that dispatch did not return `unknown tool`; it does not prove successful execution or " + "end-to-end behavioral parity. Behavioral parity requires capability fixtures.", + "", + "† Estimated as `ceil(UTF-8 response bytes / 4)`; this is not a model-tokenizer count.", + "", + "‡ Each state currently has one `tools/list` observation, so latency is descriptive only " + "and has no confidence interval.", + ) + ) + return "\n".join(lines) + "\n" + + +def render_markdown(rows: list[dict[str, Any]]) -> str: + mark_pareto_frontier(rows) + lines = [ + "# Codebase Memory performance and quality summary", + "", + "| Candidate | Decision | Overall quality† | Retrieval MRR | Pair F1 | Hit@1 | Hit@5 | nDCG@5 | " + "Core graph | Full graph freshness | Task success | Graph error | " + "Result / quality error | Run / lifecycle error | Evidence counts (R/Core/Full/S) | " + "Response p50 bytes | Response p50 tokens* | Cold/default query p50 ms | " + "Repeated JSON query p50 ms | Incremental p50 ms | " + "Peak RSS MB | Pareto |", + "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|", + ] + for row in rows: + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(row["decision"]), + display(row["overall_quality_score"], 3), + display(row["quality_score"], 3), + display(row["pair_f1_score"], 3), + display(row["hit_at_1"], 3), + display(row["hit_at_5"], 3), + display(row["ndcg_at_5"], 3), + display(row["core_graph_fidelity_score"], 3), + display(row["graph_fidelity_score"], 3), + display(row["task_success_score"], 3), + display(row["graph_error"]), + display(row["result_error"]), + display(row["run_error"]), + display( + f"{row['quality_checks']} / {row['core_graph']} / " + f"{row['canonical']} / {row['oracles']}" + ), + display(row["query_response_p50_bytes"]), + display(row["query_response_p50_tokens"]), + display(row["cold_query_latency_p50_ms"]), + display(row["query_latency_p50_ms"]), + display(row["incremental_p50_ms"]), + display(row["peak_rss_mb"]), + display(row["pareto"]), + ) + ) + + " |" + ) + pair_detail_count = sum(len(row["pair_quality_details"]) for row in rows) + if pair_detail_count: + lines.extend( + ( + "", + "## Semantic pair quality and freshness", + "", + "Confusion columns are TP/TN/FP/FN over the explicit bounded judgments. " + "Natural background pairs outside the judgment set remain unjudged.", + "", + "| Candidate | Capability | Relationship | Initial TP/TN/FP/FN | Initial F1 | " + "Post-edit TP/TN/FP/FN | Post-edit F1 | Fresh TP/TN/FP/FN | Fresh F1 | " + "Freshness policy | Freshness result | Policy conforming | Task SHA | Background commit/tree |", + "|---|---|---|---:|---:|---:|---:|---:|---:|---|---|---|---|---|", + ) + ) + for row in rows: + for detail in row["pair_quality_details"]: + revision = detail.get("background_revision") + tree = detail.get("background_tree") + background = ( + f"{str(revision)[:12]}/{str(tree)[:12]}" + if revision and tree + else "synthetic fixture" + ) + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(detail["capability"]), + display(detail["relationship"]), + display_confusion(detail["initial_confusion"]), + display(detail["initial_f1"], 3), + display_confusion(detail["incremental_confusion"]), + display(detail["incremental_f1"], 3), + display_confusion(detail["fresh_confusion"]), + display(detail["fresh_f1"], 3), + display(detail["freshness_policy"]), + display(detail["freshness"]), + display(detail["policy_conformance_met"]), + display(str(detail.get("task_sha256") or "")[:12]), + display(background), + ) + ) + + " |" + ) + comparisons = historical_delta_rows(rows) + if comparisons: + lines.extend( + ( + "", + "## Quality-constrained cross-version timing", + "", + "Rows first require matching capability overrides, accepted and identical lifecycle " + "decisions, and equal measured quality categories. Ratios are suppressed when those " + "conditions differ. Speedup is baseline latency divided by latest latency, so values " + "above 1× favor latest; fewer than three matched observations remain descriptive.", + "", + "| Latest | Baseline | Incremental speedup | Fresh rebuild speedup | " + "Query speedup | Latest quality | Baseline quality | Baseline gate | Evidence status |", + "|---|---|---:|---:|---:|---:|---:|---|---|", + ) + ) + for comparison in comparisons: + + def multiple(value: Any) -> str: + return f"{value:.2f}×" if isinstance(value, (int, float)) else "n/a" + + lines.append( + "| " + + " | ".join( + ( + display(comparison["latest"]), + display(comparison["baseline"]), + multiple(comparison["incremental_speedup"]), + multiple(comparison["full_speedup"]), + multiple(comparison["query_speedup"]), + display(comparison["latest_quality"], 3), + display(comparison["baseline_quality"], 3), + display(comparison["baseline_decision"]), + display(comparison["comparison_status"]), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "## Incremental mutation and reindex breakdown", + "", + "| Candidate | Scenario | Source mutation | Changed paths | Publication route/reason | " + "Incremental p50 ms | Indexing work p50 ms | Fresh rebuild p50 ms | " + "Fresh / incremental | Canonical equality |", + "|---|---|---|---|---|---:|---:|---:|---:|---:|", + ) + ) + for row in rows: + for detail in row["mutation_details"]: + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(detail["scenario"]), + display(detail["mutation"]), + display(detail["changed_paths"]), + display(detail["publication"]), + display(detail["incremental_p50_ms"]), + display(detail["work_p50_ms"]), + display(detail["full_p50_ms"]), + display(detail["speedup_p50"], 2), + display(detail["canonical"]), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "Incremental p50 is the end-to-end response time after applying the named source " + "mutation. Indexing work p50 isolates indexing work reported inside that response. " + "Fresh rebuild p50 indexes a separate copy of the same post-mutation tree; canonical " + "equality compares the incremental graph with that fresh reference graph.", + ) + ) + lines.extend( + ( + "", + "## Dependency-indexing capability and cost", + "", + "| Candidate | Dependency mode | Packages indexed p50 | Initial dependency p50 ms | " + "Incremental dependency p50 ms | Fresh-after-mutation dependency p50 ms |", + "|---|---|---:|---:|---:|---:|", + ) + ) + for row in rows: + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(row["dependency_mode"]), + display(row["dependency_packages_p50"]), + display(row["dependency_initial_p50_ms"]), + display(row["dependency_incremental_p50_ms"]), + display(row["dependency_fresh_p50_ms"]), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "## Observation ranges", + "", + "| Candidate | Incremental n | Incremental p50 ms | Incremental min–max ms | " + "Query n | Repeated JSON query p50 ms | Repeated JSON min–max ms | " + "Full n | Full p50 ms | Full min–max ms |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ) + ) + for row in rows: + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(row["incremental_observations"]), + display(row["incremental_p50_ms"]), + display_range(row["incremental_range_ms"]), + display(row["query_observations"]), + display(row["query_latency_p50_ms"]), + display_range(row["query_range_ms"]), + display(row["full_observations"]), + display(row["full_p50_ms"]), + display_range(row["full_range_ms"]), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "These are descriptive min–max ranges, not confidence intervals. Experiments run " + "sequentially to avoid resource contention. Rows record grouped or paired-interleaved " + "execution explicitly; interleaving reduces configuration-aligned drift but does not " + "by itself create an effect-size confidence interval. Medians and ratios remain " + "descriptive until sufficient paired repetitions are measured.", + ) + ) + lines.extend( + ( + "", + "`enabled (observed)` requires a positive recorded package count. `disabled " + "(explicit)` and `enabled (explicit)` come from exact config overrides; `unsupported` " + "requires explicit capability-support metadata. `unknown` is intentionally not guessed " + "from an old artifact that lacks those signals.", + ) + ) + lines.extend( + ( + "", + "## Algorithm-quality applicability", + "", + "| Candidate | Index mode | Rank | Similarity | Semantic edges | Git history | HTTP links | Dependencies |", + "|---|---|---|---|---|---|---|---|", + ) + ) + for row in rows: + applicability = row["capability_applicability"] + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(row["index_modes"]), + *(display(applicability[name]) for name in ALGORITHM_CAPABILITIES), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "Applicability is separate from enabled/disabled state. In particular, FAST mode " + "does not generate `SIMILAR_TO` or `SEMANTICALLY_RELATED`, so those quality effects " + "must be N/A rather than zero, pass, or failure. Retained artifacts without explicit " + "mode metadata remain `unknown`.", + ) + ) + crossovers = frontier_crossover_rows(rows) + if crossovers: + lines.extend( + ( + "", + "## Exact-frontier cap crossover", + "", + "Each row compares the largest cap that deliberately selected bounded full-index " + "fallback with the smallest measured cap that admitted exact incremental work for " + "the same mutation. Affected files include the changed root plus the generated frontier.", + "", + "| Scenario | Affected files | Fallback cap | Fallback p50 ms | Fallback work p50 ms | " + "Fallback RSS p50 MB | Exact cap | Exact p50 ms | Exact work p50 ms | " + "Exact RSS p50 MB | Fresh full p50 ms | Exact / fallback | Conclusion |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|", + ) + ) + for crossover in crossovers: + ratio_value = crossover["exact_fallback_ratio"] + rendered_ratio = ( + f"{ratio_value:.2f}×" + if isinstance(ratio_value, (int, float)) + else "n/a" + ) + lines.append( + "| " + + " | ".join( + ( + display(crossover["scenario"]), + display(crossover["affected_files"]), + display(crossover["fallback_cap"]), + display(crossover["fallback_p50_ms"]), + display(crossover["fallback_work_p50_ms"]), + display(crossover["fallback_rss_p50_mb"]), + display(crossover["exact_cap"]), + display(crossover["exact_p50_ms"]), + display(crossover["exact_work_p50_ms"]), + display(crossover["exact_rss_p50_mb"]), + display(crossover["full_p50_ms"]), + rendered_ratio, + display(crossover["conclusion"]), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "`p50 ms` is end-to-end incremental response latency; `work p50 ms` isolates the " + "indexing work reported inside that response. RSS is recorded only in benchmark " + "profiling mode. Ratios within ±5% are labelled tied.", + ) + ) + lines.extend( + ( + "", + "## Performance and provenance", + "", + "| Candidate | Cases meeting gate/target | Capabilities | Execution order | Observations (incremental/full) | Incremental p95 ms | Full p50 ms | " + "Speedup p50 | Evidence lifecycle | Binary SHA-256 |", + "|---|---:|---|---|---:|---:|---:|---:|---:|---|", + ) + ) + for row in rows: + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(row["cases"]), + display(row["capabilities"]), + display(row["execution_orders"]), + display( + f"{row['incremental_observations']}/{row['full_observations']}" + ), + display(row["incremental_p95_ms"]), + display(row["full_p50_ms"]), + display(row["speedup_p50"], 2), + display(row["lifecycle"]), + display(row["binary_sha256"]), + ) + ) + + " |" + ) + lines.extend( + ( + "", + "## Named quality-oracle breakdown", + "", + "| Candidate | Scenario | Oracle | Criterion | Expected evidence | Judgments | Result | RR | Hit@1 | Hit@5 | nDCG@5 |", + "|---|---|---|---|---|---|---|---:|---:|---:|---:|", + ) + ) + detail_count = 0 + for row in rows: + for detail in row["quality_details"]: + detail_count += 1 + lines.append( + "| " + + " | ".join( + ( + display(row["candidate"]), + display(detail["scenario"]), + display(detail["oracle"]), + display(detail["criterion"]), + display(detail["expected"]), + display(detail["judgments"]), + display(detail["result"]), + display(detail["reciprocal_rank"], 3), + display(detail["hit_at_1"]), + display(detail["hit_at_5"]), + display(detail["ndcg_at_5"], 3), + ) + ) + + " |" + ) + if not detail_count: + lines.append( + "| all | n/a | n/a | No per-oracle quality evidence recorded | n/a | n/a | " + "N/A | n/a | n/a | n/a | n/a |" + ) + lines.extend( + ( + "", + "## Correctness and quality findings", + "", + "| Candidate | Evidence |", + "|---|---|", + ) + ) + for row in rows: + if row["findings"]: + evidence = row["findings"] + elif str(row["decision"]).startswith("PASS"): + evidence = ["All applicable canonical-graph and task-oracle checks passed."] + else: + evidence = [ + f"{row['decision']}: no stage-level witness was recorded; inspect the retained " + "raw result before drawing a causal conclusion." + ] + lines.append( + f"| {display(row['candidate'])} | {display('; '.join(evidence))} |" + ) + lines.extend( + ( + "", + "## Pareto eligibility and dominance", + "", + "| Candidate | Status | Explanation |", + "|---|---|---|", + ) + ) + for row in rows: + lines.append( + f"| {display(row['candidate'])} | {display(row['pareto'])} | " + f"{display(row['pareto_reason'])} |" + ) + lines.extend( + ( + "", + "* Response tokens use the recorded `utf8_bytes_div_4_ceil` deterministic estimate; " + "bytes remain the exact default tool-response payload measurement.", + *( + ( + "", + "* Configuration labels are display-canonicalized when retained fact bundles " + "contain recorded configuration spellings used before the canonical rename; " + "the immutable input artifacts are not rewritten.", + ) + if any(row.get("pre_rename_config_spellings") for row in rows) + else () + ), + "", + "Retrieval MRR is the mean reciprocal rank of the first expected result over applicable " + "ranked probes; a missing expected result contributes zero. Hit@1 and Hit@5 are the " + "fractions of those same applicable probes whose first expected result appears by the " + "stated cutoff. N/A probes are excluded from every retrieval denominator. These definitions " + "follow NIST's official TREC QA definition: " + "[TREC QA evaluation data](https://trec.nist.gov/data/qa.html).", + "Graded probes additionally report nDCG@5, which rewards placing more-relevant " + "evidence earlier while normalizing against the ideal judged ordering. MRR and Hit@k " + "remain visible because they answer the distinct first-useful-result question. This " + "follows Järvelin and Kekäläinen's primary definition in " + "[Cumulated Gain-based Evaluation of IR Techniques]" + "(https://doi.org/10.1145/582415.582418).", + "", + "Graph fidelity is split into two visible categories. Core graph is the fraction of " + "mutation cases whose non-stale canonical rows equal a " + "matching-mode fresh rebuild. A declared-stale gate can pass only when the harness removes " + "the specifically named derived rows and every remaining canonical node, edge, property, " + "and file hash still matches. Full graph freshness requires unfiltered canonical equality. " + "Task success is the fraction of applicable probes that find " + "their required evidence. Pair F1 is the mean of the explicit initial and fresh semantic-" + "pair classification tasks; an expected deferred post-edit view remains visible separately " + "and does not masquerade as retrieval MRR. Evidence counts show retrieval probes / graph comparisons / strict " + "whole-scenario passes. The named breakdown above shows why a result is, for example, 4/5 " + "rather than hiding the failed task.", + "", + "† Overall quality is a custom descriptive score: the equal-weight geometric mean of " + "result quality, full graph freshness, and task success. Result quality is Pair F1 or " + "MRR when only one is measured, and their arithmetic mean when both are measured. It is " + "N/A unless all three categories are measured. It never overrides a graph-correctness gate. " + "A required mutation oracle can " + "reject a correctness benchmark; an algorithm-ablation oracle that misses its declared " + "cutoff is labelled BELOW QUALITY TARGET instead of being called broken. Category values " + "remain visible so the aggregate cannot hide which capability changed.", + "", + "Query p50 aggregates the recorded default-response oracle calls. Indexing p50/p95 use only " + "the recorded indexing observations; consult Cases and the immutable experiment manifest before " + "treating a small pilot as a population estimate.", + "Performance ratios require matched experiment identities and enough independent repetitions " + "for an effect-size confidence interval. This report shows observation counts and does not " + "invent an interval for one- or three-observation pilots. The experiment-design rationale " + "follows [Kalibera and Jones, Quantifying Performance Changes with Effect Size Confidence " + "Intervals](https://arxiv.org/abs/2007.10899).", + "", + "Pareto status considers only candidates that meet the declared quality target, pass " + "correctness, and have every axis measured. It maximizes overall quality while minimizing " + "incremental and query latency, response-token estimate, and peak RSS. This is exact " + "pairwise nondominance over the measured candidates, using the Pareto relation described " + "by [Deb et al.](https://doi.org/10.1109/4235.996017); it does not run NSGA-II.", + "", + "A speedup is accepted only when the case gate and every applicable canonical-graph " + "and task-oracle check pass. `n/a` means the input artifact did not measure that axis.", + ) + ) + return "\n".join(lines) + "\n" + + +def parse_input(value: str) -> tuple[str, Path]: + label, separator, raw_path = value.partition("=") + if not separator or not label or not raw_path: + raise argparse.ArgumentTypeError("--input expects LABEL=PATH") + return label, Path(raw_path).expanduser() + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_experiment_runner() -> Any: + path = Path(__file__).resolve().with_name("run_experiments.py") + spec = importlib.util.spec_from_file_location( + "run_benchmark_experiment_for_summary", path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load experiment runner: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _composition_path(base: Path, value: Any, field: str) -> Path: + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty path string") + path = Path(value).expanduser() + return (base / path).resolve() if not path.is_absolute() else path.resolve() + + +def load_composition_groups( + composition_path: Path, experiment_runner: Any | None = None +) -> tuple[dict[str, list[dict[str, Any]]], dict[str, Any]]: + """Resolve completed experiment cells into exact cross-scenario report groups.""" + composition_path = composition_path.expanduser().resolve() + with composition_path.open(encoding="utf-8") as stream: + composition = json.load(stream) + if not isinstance(composition, dict) or composition.get("schema_version") != 1: + raise ValueError("composition schema_version must be 1") + groups = composition.get("groups") + if not isinstance(groups, list) or not groups: + raise ValueError("composition groups must be a non-empty array") + experiments = composition.get("experiments") + legacy_experiments = composition.get("campaigns") + if experiments is not None and legacy_experiments is not None: + raise ValueError("composition must not mix experiments with legacy campaigns") + legacy_layout = experiments is None and legacy_experiments is not None + if legacy_layout: + experiments = legacy_experiments + if not isinstance(experiments, dict) or not experiments: + raise ValueError("composition experiments must be a non-empty object") + runner = experiment_runner or load_experiment_runner() + base = composition_path.parent + resolved_experiments: dict[str, tuple[list[dict[str, Any]], Path]] = {} + experiment_records: list[dict[str, Any]] = [] + for experiment_name, experiment in experiments.items(): + if ( + not isinstance(experiment_name, str) + or not experiment_name + or not isinstance(experiment, dict) + ): + raise ValueError( + "composition experiment entries must have non-empty names and objects" + ) + prefix = f"experiments.{experiment_name}" + matrix_value = experiment.get("matrix_spec") + plan_value = experiment.get("plan") + if (matrix_value is None) == (plan_value is None): + raise ValueError( + f"{prefix} must declare exactly one of matrix_spec or plan" + ) + root_value = experiment.get("experiment_root") + if root_value is None and legacy_layout: + root_value = experiment.get("campaign_root") + experiment_root = _composition_path( + base, root_value, f"{prefix}.experiment_root" + ) + if plan_value is not None: + source_path = _composition_path(base, plan_value, f"{prefix}.plan") + with source_path.open(encoding="utf-8") as stream: + plan = json.load(stream) + cells = runner.validate_plan(plan) + source_kind = "immutable_plan" + else: + source_path = _composition_path(base, matrix_value, f"{prefix}.matrix_spec") + with source_path.open(encoding="utf-8") as stream: + matrix_spec = json.load(stream) + plan = runner.expand_matrix_spec(matrix_spec) + cells = plan.get("cells") if isinstance(plan, dict) else None + if not isinstance(cells, list): + raise ValueError(f"{prefix}.matrix_spec did not expand to cells") + source_kind = "live_matrix_expansion" + resolved_experiments[experiment_name] = (cells, experiment_root) + experiment_records.append( + { + "experiment": experiment_name, + "source_kind": source_kind, + "source_path": str(source_path), + "source_sha256": file_sha256(source_path), + } + ) + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + input_records: list[dict[str, Any]] = [] + seen_labels: set[str] = set() + for group_index, group in enumerate(groups): + if not isinstance(group, dict): + raise ValueError(f"groups[{group_index}] must be an object") + label = group.get("label") + if not isinstance(label, str) or not label or label in seen_labels: + raise ValueError( + f"groups[{group_index}].label must be non-empty and unique" + ) + seen_labels.add(label) + inputs = group.get("inputs") + if not isinstance(inputs, list) or not inputs: + raise ValueError(f"groups[{group_index}].inputs must be a non-empty array") + for source_index, source in enumerate(inputs): + if not isinstance(source, dict): + raise ValueError( + f"groups[{group_index}].inputs[{source_index}] must be an object" + ) + prefix = f"groups[{group_index}].inputs[{source_index}]" + experiment_name = source.get("experiment") + if experiment_name is None and legacy_layout: + experiment_name = source.get("campaign") + if ( + not isinstance(experiment_name, str) + or experiment_name not in resolved_experiments + ): + raise ValueError(f"{prefix}.experiment must name a declared experiment") + cells, experiment_root = resolved_experiments[experiment_name] + cell_labels = source.get("cell_labels") + if ( + not isinstance(cell_labels, list) + or not cell_labels + or not all(isinstance(item, str) and item for item in cell_labels) + ): + raise ValueError( + f"{prefix}.cell_labels must be a non-empty string array" + ) + requested = set(cell_labels) + selected = [cell for cell in cells if cell.get("label") in requested] + found = {cell.get("label") for cell in selected} + missing_labels = sorted(requested - found) + if missing_labels: + raise ValueError( + f"{prefix} cell labels not found: {', '.join(missing_labels)}" + ) + inputs = runner.completed_report_inputs(experiment_root, selected) + if len(inputs) != len(selected): + raise ValueError( + f"{prefix} has {len(inputs)} validated completions for {len(selected)} cells" + ) + for cell_label, input_path in inputs: + with input_path.open(encoding="utf-8") as stream: + document = json.load(stream) + if not isinstance(document, dict): + raise ValueError(f"expected JSON object in {input_path}") + grouped[label].append(document) + input_records.append( + { + "group": label, + "cell_label": cell_label, + "input_path": str(input_path.resolve()), + "input_sha256": file_sha256(input_path), + } + ) + provenance = { + "schema_version": 1, + "spec_path": str(composition_path), + "spec_sha256": file_sha256(composition_path), + "experiments": experiment_records, + "input_count": len(input_records), + "inputs": input_records, + } + return grouped, provenance + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", action="append", default=[], type=parse_input) + parser.add_argument( + "--composition-spec", + type=Path, + help="Compose exact labels from validated cells in multiple durable experiments.", + ) + parser.add_argument( + "--mcp-surface-parity", + action="append", + default=[], + type=Path, + help="Append a three-state MCP surface section from a retained parity JSON result.", + ) + parser.add_argument( + "--list-projects-scaling", + action="append", + default=[], + type=Path, + help="Append a list_projects response-scaling section from retained JSON.", + ) + parser.add_argument( + "--search-projection", + action="append", + default=[], + type=Path, + help="Append a search_graph compact-projection section from retained JSON.", + ) + parser.add_argument("--out", default="") + args = parser.parse_args() + if ( + not args.input + and not args.composition_spec + and not args.mcp_surface_parity + and not args.list_projects_scaling + and not args.search_projection + ): + parser.error( + "at least one --input, --composition-spec, --mcp-surface-parity, " + "--list-projects-scaling, or --search-projection is required" + ) + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + composition_provenance: dict[str, Any] | None = None + for label, path in args.input: + with path.open(encoding="utf-8") as stream: + document = json.load(stream) + if not isinstance(document, dict): + raise SystemExit(f"error: expected JSON object in {path}") + grouped[label].append(document) + if args.composition_spec: + try: + composed, composition_provenance = load_composition_groups( + args.composition_spec + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"error: invalid composition spec: {exc}") from exc + for label, documents in composed.items(): + grouped[label].extend(documents) + sections: list[str] = [] + if grouped: + sections.append( + render_markdown( + [summarize_group(label, reports) for label, reports in grouped.items()] + ).rstrip() + ) + for raw_path in args.mcp_surface_parity: + path = raw_path.expanduser() + with path.open(encoding="utf-8") as stream: + document = json.load(stream) + if not isinstance(document, dict): + raise SystemExit(f"error: expected JSON object in {path}") + sections.append(render_mcp_surface_parity(document).rstrip()) + for raw_path in args.list_projects_scaling: + path = raw_path.expanduser() + with path.open(encoding="utf-8") as stream: + document = json.load(stream) + if not isinstance(document, dict): + raise SystemExit(f"error: expected JSON object in {path}") + sections.append(render_list_projects_scaling(document).rstrip()) + for raw_path in args.search_projection: + path = raw_path.expanduser() + with path.open(encoding="utf-8") as stream: + document = json.load(stream) + if not isinstance(document, dict): + raise SystemExit(f"error: expected JSON object in {path}") + sections.append(render_search_projection(document).rstrip()) + if composition_provenance: + sections.append( + "\n".join( + ( + "## Composition provenance", + "", + f"- Spec: `{composition_provenance['spec_path']}`", + f"- Spec SHA-256: `{composition_provenance['spec_sha256']}`", + f"- Validated experiment inputs: {composition_provenance['input_count']}", + "- Per-input paths and SHA-256 values are retained in the sidecar manifest.", + ) + ) + ) + markdown = "\n\n".join(sections) + "\n" + if args.out: + output = Path(args.out).expanduser() + atomic_write_text(output, markdown) + if composition_provenance: + manifest_output = output.with_name(output.name + ".manifest.json") + atomic_write_text( + manifest_output, + json.dumps(composition_provenance, indent=2, sort_keys=True) + "\n", + ) + print(markdown, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/terminology.json b/benchmarks/terminology.json new file mode 100644 index 000000000..f0935a657 --- /dev/null +++ b/benchmarks/terminology.json @@ -0,0 +1,2186 @@ +{ + "$schema": "benchmarks/schema/terminology.schema.json", + "entries": [ + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A benchmark run is one execution of the measured product operation with one resolved implementation, capability, scope, and cache manifest.", + "deprecated_replacement": null, + "display_name": "Benchmark Run", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "benchmark_run", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A repetition is one independently started benchmark run in a cell; repetitions share the cell configuration but not mutable process state unless the cache manifest says otherwise.", + "deprecated_replacement": null, + "display_name": "Repetition", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "repetition", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A benchmark cell is the set of repetitions that share one declared implementation, workload, effective capability manifest, scope manifest, cache manifest, and correctness contract.", + "deprecated_replacement": null, + "display_name": "Benchmark Cell", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "benchmark_cell", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A user lifecycle is one user-visible operation measured between two harness-owned monotonic boundary events.", + "deprecated_replacement": null, + "display_name": "User Lifecycle", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "user_lifecycle", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A step is a registry-defined kind of work performed during a benchmark run.", + "deprecated_replacement": null, + "display_name": "Step", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "step", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A step occurrence is one execution of a step; every repeated or concurrent occurrence has its own occurrence ID.", + "deprecated_replacement": null, + "display_name": "Step Occurrence", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "step_occurrence", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A parent relation records structural nesting between two step occurrences and does not by itself impose execution order.", + "deprecated_replacement": null, + "display_name": "Parent Relation", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "parent_relation", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A dependency relation records that one step occurrence must reach a named event before another occurrence can proceed.", + "deprecated_replacement": null, + "display_name": "Dependency Relation", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "dependency_relation", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A benchmark result is one recorded correctness, freshness, retrieval, ranking, semantic-quality, skip, error, or product-failure outcome for a benchmark run.", + "deprecated_replacement": null, + "display_name": "Benchmark Result", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "benchmark_result", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A retained artifact is one benchmark input or output identified by path, content hash, schema version, terminology version, and cleanup state.", + "deprecated_replacement": null, + "display_name": "Retained Artifact", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "retained_artifact", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "An implementation identity is the source revision, binary hash, and build manifest of the compared executable.", + "deprecated_replacement": null, + "display_name": "Implementation Identity", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "implementation_identity", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A production build is an executable built with the shipped optimization, sanitizer, and feature flags recorded in its build manifest.", + "deprecated_replacement": null, + "display_name": "Production Build", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "production_build", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "enabled, disabled, unsupported, or an exact enumerated/numeric value", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines parity eligibility and the work/correctness contract", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "per-call override > environment > persistent config > preset > compiled default", + "data_type": "capability record or categorical state", + "definition": "A capability is one separately observable product behavior.", + "deprecated_replacement": null, + "display_name": "Capability", + "examples": [], + "introduced_version": "1.0.0", + "kind": "capability_state", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "capability", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "enabled, disabled, unsupported, or an exact enumerated/numeric value", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines parity eligibility and the work/correctness contract", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "per-call override > environment > persistent config > preset > compiled default", + "data_type": "capability record or categorical state", + "definition": "An effective capability value is selected after applying default, preset, persistent-config, environment, and per-call precedence; its winning source is recorded.", + "deprecated_replacement": null, + "display_name": "Effective Capability Value", + "examples": [], + "introduced_version": "1.0.0", + "kind": "capability_state", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "effective_capability_value", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "enabled, disabled, unsupported, or an exact enumerated/numeric value", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines parity eligibility and the work/correctness contract", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "per-call override > environment > persistent config > preset > compiled default", + "data_type": "capability record or categorical state", + "definition": "An enabled capability is implemented by the measured executable and active for the benchmark run.", + "deprecated_replacement": null, + "display_name": "Enabled Capability", + "examples": [], + "introduced_version": "1.0.0", + "kind": "capability_state", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "enabled_capability", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "enabled, disabled, unsupported, or an exact enumerated/numeric value", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines parity eligibility and the work/correctness contract", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "per-call override > environment > persistent config > preset > compiled default", + "data_type": "capability record or categorical state", + "definition": "A disabled capability is implemented by the measured executable but inactive for the benchmark run.", + "deprecated_replacement": null, + "display_name": "Disabled Capability", + "examples": [], + "introduced_version": "1.0.0", + "kind": "capability_state", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "disabled_capability", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "enabled, disabled, unsupported, or an exact enumerated/numeric value", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines parity eligibility and the work/correctness contract", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "per-call override > environment > persistent config > preset > compiled default", + "data_type": "capability record or categorical state", + "definition": "An unsupported capability is unavailable in the measured executable; missing capability metadata instead makes the benchmark record invalid.", + "deprecated_replacement": null, + "display_name": "Unsupported Capability", + "examples": [], + "introduced_version": "1.0.0", + "kind": "capability_state", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "unsupported_capability", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A scope manifest identifies every included repository, dependency package, file and byte count, language, generated-source policy, and exclusion.", + "deprecated_replacement": null, + "display_name": "Scope Manifest", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "scope_manifest", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A cache manifest records the state and reset procedure for every named cache layer; the report does not use an unqualified cold or warm label.", + "deprecated_replacement": null, + "display_name": "Cache Manifest", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "cache_manifest", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A core graph contains the source-derived nodes, edges, properties, and file hashes that remain after removing only the optional derived views explicitly listed in the benchmark result.", + "deprecated_replacement": null, + "display_name": "Core Graph", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "core_graph", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A core answer is a task answer computed from a core graph whose source generation matches the latest successful source publication.", + "deprecated_replacement": null, + "display_name": "Core Answer", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "core_answer", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A derived view is named data recomputed from the source graph.", + "deprecated_replacement": null, + "display_name": "Derived View", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "derived_view", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A source generation is the monotonic identifier assigned to one successful publication of source-derived graph data.", + "deprecated_replacement": null, + "display_name": "Source Generation", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "source_generation", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A view generation is the source generation used to compute one named derived view.", + "deprecated_replacement": null, + "display_name": "View Generation", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "view_generation", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A fresh view is a derived view whose view generation equals the latest successfully published source generation at the measured endpoint.", + "deprecated_replacement": null, + "display_name": "Fresh View", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "fresh_view", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A stale view is a derived view whose view generation precedes the latest successfully published source generation.", + "deprecated_replacement": null, + "display_name": "Stale View", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "stale_view", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "An eager refresh computes and publishes the named derived view before the measured endpoint returns.", + "deprecated_replacement": null, + "display_name": "Eager Refresh", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "eager_refresh", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A deferred refresh leaves the named derived view stale at the measured endpoint and reports its stale state and generation.", + "deprecated_replacement": null, + "display_name": "Deferred Refresh", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "deferred_refresh", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A requested-fresh endpoint occurs when the core graph and every enabled derived view required by the named task are fresh.", + "deprecated_replacement": null, + "display_name": "Requested Fresh Endpoint", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "requested_fresh_endpoint", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "An all-fresh endpoint occurs when the core graph and every enabled derived view in the effective capability manifest are fresh.", + "deprecated_replacement": null, + "display_name": "All Fresh Endpoint", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "all_fresh_endpoint", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A correctness contract specifies the graph rows, properties, hashes, freshness states, task outcomes, and allowed exclusions that a benchmark result must satisfy.", + "deprecated_replacement": null, + "display_name": "Correctness Contract", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "correctness_contract", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "A clean-rebuild graph oracle is the canonically normalized graph produced from an empty store using the same source snapshot, effective capability manifest, and scope manifest as the compared run.", + "deprecated_replacement": null, + "display_name": "Clean Rebuild Graph Oracle", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "clean_rebuild_graph_oracle", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the recorded correctness contract", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "determines whether a lifecycle reached core, requested-fresh, or all-fresh completion", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "freshness, generation, oracle, or endpoint record", + "definition": "Graph equality means equality under the recorded canonicalization version and does not require byte-identical SQLite files.", + "deprecated_replacement": null, + "display_name": "Graph Equality", + "examples": [], + "introduced_version": "1.0.0", + "kind": "freshness_and_correctness", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "graph_equality", + "unit": "not_applicable" + }, + { + "aggregation_rule": "derive only from source run, result, and step IDs retained in the record", + "allowed_values_or_range": "one comparison whose join and formula identifiers are recorded", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "comparison record", + "definition": "A parity comparison compares two benchmark cells whose effective capabilities, input and scope policies, per-layer cache states, timer boundaries, freshness endpoints, and correctness contracts are identical.", + "deprecated_replacement": null, + "display_name": "Parity Comparison", + "examples": [], + "introduced_version": "1.0.0", + "kind": "comparison_kind", + "missing_or_unsupported_behavior": "reject the comparison when a required join field is unknown", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "parity_comparison", + "unit": "not_applicable" + }, + { + "aggregation_rule": "derive only from source run, result, and step IDs retained in the record", + "allowed_values_or_range": "one comparison whose join and formula identifiers are recorded", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "comparison record", + "definition": "A shared-work projection compares the explicitly named intersection of work supported by two implementations and is not whole-product parity.", + "deprecated_replacement": null, + "display_name": "Shared Work Projection", + "examples": [], + "introduced_version": "1.0.0", + "kind": "comparison_kind", + "missing_or_unsupported_behavior": "reject the comparison when a required join field is unknown", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "shared_work_projection", + "unit": "not_applicable" + }, + { + "aggregation_rule": "derive only from source run, result, and step IDs retained in the record", + "allowed_values_or_range": "one comparison whose join and formula identifiers are recorded", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "comparison record", + "definition": "A capability-delta comparison compares cells with an explicitly named capability difference and reports the added or removed work, quality, coverage, and resource cost without a cross-implementation speed ratio.", + "deprecated_replacement": null, + "display_name": "Capability Delta Comparison", + "examples": [], + "introduced_version": "1.0.0", + "kind": "comparison_kind", + "missing_or_unsupported_behavior": "reject the comparison when a required join field is unknown", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "capability_delta_comparison", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A timer boundary is a registry-defined event, owned by the harness or a named process, that starts or ends a duration.", + "deprecated_replacement": null, + "display_name": "Timer Boundary", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "timer_boundary", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start", + "allowed_values_or_range": "0 or greater", + "boundary_semantics": "the registered start event through the registered end event", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named monotonic clock and thread, process, process-tree, or harness scope", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "A step occurrence's elapsed time is its monotonic end timestamp minus its monotonic start timestamp.", + "deprecated_replacement": null, + "display_name": "Elapsed Time", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "elapsed_time", + "unit": "milliseconds in fact tables; source clocks use nanoseconds" + }, + { + "aggregation_rule": "aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start", + "allowed_values_or_range": "0 or greater", + "boundary_semantics": "the registered start event through the registered end event", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named monotonic clock and thread, process, process-tree, or harness scope", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "A user lifecycle's wall time is its harness-owned end boundary minus its harness-owned start boundary.", + "deprecated_replacement": null, + "display_name": "Lifecycle Wall Time", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "lifecycle_wall_time", + "unit": "milliseconds in fact tables; source clocks use nanoseconds" + }, + { + "aggregation_rule": "aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start", + "allowed_values_or_range": "0 or greater", + "boundary_semantics": "the registered start event through the registered end event", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named monotonic clock and thread, process, process-tree, or harness scope", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Work time is the sum of selected step-occurrence elapsed times and may exceed lifecycle wall time when occurrences overlap.", + "deprecated_replacement": null, + "display_name": "Work Time", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "work_time", + "unit": "milliseconds in fact tables; source clocks use nanoseconds" + }, + { + "aggregation_rule": "aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start", + "allowed_values_or_range": "0 or greater", + "boundary_semantics": "the registered start event through the registered end event", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named monotonic clock and thread, process, process-tree, or harness scope", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "CPU time is processor execution time measured for a named thread, process, or child-process set.", + "deprecated_replacement": null, + "display_name": "Cpu Time", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "cpu_time", + "unit": "milliseconds in fact tables; source clocks use nanoseconds" + }, + { + "aggregation_rule": "aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start", + "allowed_values_or_range": "0 or greater", + "boundary_semantics": "the registered start event through the registered end event", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named monotonic clock and thread, process, process-tree, or harness scope", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "A step occurrence's queue wait is its worker-start timestamp minus its enqueue timestamp.", + "deprecated_replacement": null, + "display_name": "Queue Wait", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "queue_wait", + "unit": "milliseconds in fact tables; source clocks use nanoseconds" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Two step occurrences overlap when their monotonic execution intervals intersect.", + "deprecated_replacement": null, + "display_name": "Overlap", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "overlap", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A user lifecycle's critical path is the longest-duration path through its explicit dependency relations.", + "deprecated_replacement": null, + "display_name": "Critical Path", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "critical_path", + "unit": "not_applicable" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Parallelism is the number of step occurrences actively executing during a declared monotonic interval.", + "deprecated_replacement": null, + "display_name": "Parallelism", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "parallelism", + "unit": "dimensionless" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Worker utilization is active worker time divided by available worker time for a named worker pool and interval.", + "deprecated_replacement": null, + "display_name": "Worker Utilization", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "worker_utilization", + "unit": "dimensionless" + }, + { + "aggregation_rule": "peak uses max; delta uses end minus start; never add peaks", + "allowed_values_or_range": "peak_rss is nonnegative; rss_delta may be negative", + "boundary_semantics": "the registered lifecycle or step sampling boundaries", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named process or process-tree sampling interval", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "number", + "definition": "Peak resident set size is the largest resident-memory sample observed for the named process set within declared boundaries.", + "deprecated_replacement": null, + "display_name": "Peak RSS", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "peak_rss", + "unit": "MiB" + }, + { + "aggregation_rule": "peak uses max; delta uses end minus start; never add peaks", + "allowed_values_or_range": "peak_rss is nonnegative; rss_delta may be negative", + "boundary_semantics": "the registered lifecycle or step sampling boundaries", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the named process or process-tree sampling interval", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "number", + "definition": "Resident-set delta is end-boundary RSS minus start-boundary RSS for the named process set.", + "deprecated_replacement": null, + "display_name": "RSS delta", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "rss_delta", + "unit": "MiB" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "A ratio is a named numerator divided by a named nonzero denominator under one declared comparison contract.", + "deprecated_replacement": null, + "display_name": "Ratio", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "ratio", + "unit": "dimensionless" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "A speedup is baseline duration divided by candidate duration under one declared parity or shared-work-projection contract; values above 1 mean the candidate completed faster.", + "deprecated_replacement": null, + "display_name": "Speedup", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "speedup", + "unit": "dimensionless" + }, + { + "aggregation_rule": "apply only the versioned estimator to one declared repetition set", + "allowed_values_or_range": "values permitted by the measured quantity", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "number or bounded interval", + "definition": "A median is the versioned 50th-percentile estimator over the recorded repetitions in one benchmark cell.", + "deprecated_replacement": null, + "display_name": "Median", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "median", + "unit": "the unit of the measured quantity" + }, + { + "aggregation_rule": "apply only the versioned estimator to one declared repetition set", + "allowed_values_or_range": "values permitted by the measured quantity", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "number or bounded interval", + "definition": "A p95 value is the versioned 95th-percentile estimator over the recorded repetitions in one benchmark cell.", + "deprecated_replacement": null, + "display_name": "p95", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "p95", + "unit": "the unit of the measured quantity" + }, + { + "aggregation_rule": "apply only the versioned estimator to one declared repetition set", + "allowed_values_or_range": "values permitted by the measured quantity", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "number or bounded interval", + "definition": "A confidence interval is the interval produced by the recorded statistical method, confidence level, and repetition set for one estimator.", + "deprecated_replacement": null, + "display_name": "Confidence Interval", + "examples": [], + "introduced_version": "1.0.0", + "kind": "measurement", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "confidence_interval", + "unit": "the unit of the measured quantity" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Mean reciprocal rank is the mean of 1/rank for the first correct returned entity in each applicable retrieval task; higher is better and 1 means every correct entity ranked first.", + "deprecated_replacement": null, + "display_name": "MRR", + "examples": [], + "introduced_version": "1.0.0", + "kind": "quality_metric", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "mrr", + "unit": "dimensionless" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Hit@k is the fraction of applicable retrieval tasks whose named correct entity appears within the first k returned entities; higher is better.", + "deprecated_replacement": null, + "display_name": "Hit@k", + "examples": [], + "introduced_version": "1.0.0", + "kind": "quality_metric", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "hit_at_k", + "unit": "dimensionless" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Normalized discounted cumulative gain at k scores the order of judged returned entities within the first k positions against the ideal order; higher is better and 1 is ideal.", + "deprecated_replacement": null, + "display_name": "nDCG@k", + "examples": [], + "introduced_version": "1.0.0", + "kind": "quality_metric", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "ndcg_at_k", + "unit": "dimensionless" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Semantic Pair F1 is the harmonic mean of precision and recall over the explicitly judged SEMANTICALLY_RELATED code-entity pairs; higher is better and 1 means none are missing or spurious.", + "deprecated_replacement": null, + "display_name": "Semantic Pair F1", + "examples": [], + "introduced_version": "1.0.0", + "kind": "quality_metric", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "semantic_pair_f1", + "unit": "dimensionless" + }, + { + "aggregation_rule": "use the formula and repetition estimator recorded with the result", + "allowed_values_or_range": "0 or greater; ratio denominators must be nonzero", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "nonnegative number", + "definition": "Task success is the fraction of applicable named tasks that return their required entity or evidence under the task's recorded acceptance rule.", + "deprecated_replacement": null, + "display_name": "Task Success", + "examples": [], + "introduced_version": "1.0.0", + "kind": "quality_metric", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "task_success", + "unit": "dimensionless" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "An invalid benchmark record is measurement evidence rejected because its instrumentation, schema, terminology, or oracle requirements failed.", + "deprecated_replacement": null, + "display_name": "Invalid Benchmark Record", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "invalid_benchmark_record", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A product failure occurs when the indexed or query operation violates its recorded product contract or returns a failing product status.", + "deprecated_replacement": null, + "display_name": "Product Failure", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "product_failure", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Observer effect is the latency, CPU, or memory difference caused by instrumentation, measured against profiler-off cells using the same executable and workload.", + "deprecated_replacement": null, + "display_name": "Observer Effect", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "observer_effect", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Generated source is machine-produced or vendored source selected by an explicit recorded policy.", + "deprecated_replacement": null, + "display_name": "Generated Source", + "examples": [], + "introduced_version": "1.0.0", + "kind": "benchmark_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "generated_source", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A semantic vector is the recorded numeric representation of one code entity used by the semantic-similarity algorithm.", + "deprecated_replacement": null, + "display_name": "Semantic Vector", + "examples": [], + "introduced_version": "1.0.0", + "kind": "algorithm_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "semantic_vector", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "A locality-sensitive-hashing index groups semantic vectors into candidate buckets so the semantic pass need not compare every pair.", + "deprecated_replacement": null, + "display_name": "LSH index", + "examples": [], + "introduced_version": "1.0.0", + "kind": "algorithm_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "lsh_index", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "PageRank is the configured graph-centrality score computed from incoming weighted graph links. The same `pagerank` registry ID identifies each measured occurrence of that work; repeated or concurrent occurrences have distinct occurrence IDs.", + "deprecated_replacement": null, + "display_name": "PageRank", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "pagerank", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "LinkRank is the configured edge score derived from stationary flow between graph nodes. The same `linkrank` registry ID identifies each measured occurrence of that work; repeated or concurrent occurrences have distinct occurrence IDs.", + "deprecated_replacement": null, + "display_name": "LinkRank", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "linkrank", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Node degree is the configured weighted, unweighted, or calls-only connection count for one graph node.", + "deprecated_replacement": null, + "display_name": "Node Degree", + "examples": [], + "introduced_version": "1.0.0", + "kind": "algorithm_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "node_degree", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Graph publication is the transaction that makes computed node, edge, property, index, and generation changes visible in the persistent store.", + "deprecated_replacement": null, + "display_name": "Graph Publication", + "examples": [], + "introduced_version": "1.0.0", + "kind": "algorithm_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "graph_publication", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Dependency artifact reuse loads a previously computed dependency graph only when package identity, source hash, parser version, config and schema version, and capability set all match.", + "deprecated_replacement": null, + "display_name": "Dependency Artifact Reuse", + "examples": [], + "introduced_version": "1.0.0", + "kind": "algorithm_concept", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "dependency_artifact_reuse", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Existing behavior is behavior present at the cited source revision and verified at the cited code or experiment anchor.", + "deprecated_replacement": null, + "display_name": "Existing Behavior", + "examples": [], + "introduced_version": "1.0.0", + "kind": "evidence_status", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "existing_behavior", + "unit": "not_applicable" + }, + { + "aggregation_rule": "must not be aggregated unless a referenced formula defines the operation", + "allowed_values_or_range": "values defined by the referenced benchmark schema", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "none beyond the term's normative definition", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; use recorded occurrence intervals and dependency relations", + "configuration_precedence": "not_applicable", + "data_type": "object or categorical record", + "definition": "Proposed behavior is design work described by this plan but not implemented at the cited source revision.", + "deprecated_replacement": null, + "display_name": "Proposed Behavior", + "examples": [], + "introduced_version": "1.0.0", + "kind": "evidence_status", + "missing_or_unsupported_behavior": "record an explicit unknown fact; do not infer the value from a label or preset", + "source_anchors": [ + "benchmarks/schema/facts-v2.schema.json", + "benchmarks/run_benchmark.py" + ], + "status": "existing", + "term_id": "proposed_behavior", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `startup`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `startup` step ID identifies one occurrence of startup work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Startup", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "startup", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `project_discovery`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `project_discovery` step ID identifies one occurrence of project discovery work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Project Discovery", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "project_discovery", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `change_classification`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `change_classification` step ID identifies one occurrence of change classification work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Change Classification", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "change_classification", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `parse_extract`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `parse_extract` step ID identifies one occurrence of parse extract work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Parse Extract", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "parse_extract", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `exact_delta`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `exact_delta` step ID identifies one occurrence of exact delta work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Exact Delta", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "exact_delta", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `semantic_vectors`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `semantic_vectors` step ID identifies one occurrence of semantic vectors work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Semantic Vectors", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "semantic_vectors", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `semantic_lsh`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `semantic_lsh` step ID identifies one occurrence of semantic lsh work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Semantic Lsh", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "semantic_lsh", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `semantic_pairs`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `semantic_pairs` step ID identifies one occurrence of semantic pairs work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Semantic Pairs", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "semantic_pairs", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `graph_publish_delete`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `graph_publish_delete` step ID identifies one occurrence of graph publish delete work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Graph Publish Delete", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "graph_publish_delete", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `graph_publish_upsert`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `graph_publish_upsert` step ID identifies one occurrence of graph publish upsert work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Graph Publish Upsert", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "graph_publish_upsert", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `graph_publish_indexes`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `graph_publish_indexes` step ID identifies one occurrence of graph publish indexes work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Graph Publish Indexes", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "graph_publish_indexes", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `dependency_discovery`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `dependency_discovery` step ID identifies one occurrence of dependency discovery work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Dependency Discovery", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "dependency_discovery", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `dependency_package_index`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `dependency_package_index` step ID identifies one occurrence of dependency package index work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "Dependency Package Index", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "dependency_package_index", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `first_core_query`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `first_core_query` step ID identifies one occurrence of first core query work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "First Core Query", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "first_core_query", + "unit": "not_applicable" + }, + { + "aggregation_rule": "aggregate only distinct occurrence IDs selected by an emitted formula", + "allowed_values_or_range": "exactly `first_all_fresh_query`", + "boundary_semantics": "the occurrence's registered start and end events", + "capability_or_freshness_implications": "the enclosing run records the capability and freshness contract; the step ID alone implies neither", + "clock_or_cpu_scope": "the thread, worker, process, or harness recorded on each occurrence", + "concurrency_rule": "overlapping occurrences remain separate and are not summed into lifecycle wall time", + "configuration_precedence": "profiling level and benchmark specification select whether this step is emitted", + "data_type": "stable string identifier", + "definition": "The `first_all_fresh_query` step ID identifies one occurrence of first all fresh query work; each repeated or concurrent occurrence has a distinct occurrence ID.", + "deprecated_replacement": null, + "display_name": "First All Fresh Query", + "examples": [], + "introduced_version": "1.0.0", + "kind": "step_id", + "missing_or_unsupported_behavior": "omit no required occurrence; mark the benchmark record invalid if detail collection overflows", + "source_anchors": [ + "src/foundation/profile.h", + "benchmarks/run_benchmark.py" + ], + "status": "proposed", + "term_id": "first_all_fresh_query", + "unit": "not_applicable" + }, + { + "aggregation_rule": "not_applicable", + "allowed_values_or_range": "exactly `parity_manifest_and_contract_v1`", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "requires identical effective capability, scope, cache, host, benchmark-contract, and correctness-contract records", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; compared durations retain their recorded occurrence structure", + "configuration_precedence": "not_applicable", + "data_type": "stable string identifier", + "definition": "The `parity_manifest_and_contract_v1` join ID selects two cells only when mode, effective capabilities, scope, cache state, host, benchmark contract, and correctness contract are canonically equal, both capability manifests are complete, and no required manifest value is unknown.", + "deprecated_replacement": null, + "display_name": "Parity Manifest and Contract Join v1", + "examples": [], + "introduced_version": "1.1.0", + "kind": "join_id", + "missing_or_unsupported_behavior": "classify the pair as not eligible and emit no ratio", + "source_anchors": [ + "benchmarks/schema/comparisons-v1.schema.json", + "benchmarks/fact_comparisons.py" + ], + "status": "existing", + "term_id": "parity_manifest_and_contract_v1", + "unit": "not_applicable" + }, + { + "aggregation_rule": "not_applicable", + "allowed_values_or_range": "exactly `capability_delta_manifest_v1`", + "boundary_semantics": "not_applicable", + "capability_or_freshness_implications": "requires one or more explicitly recorded capability differences", + "clock_or_cpu_scope": "not_applicable", + "concurrency_rule": "does not imply serial execution; compared durations retain their recorded occurrence structure", + "configuration_precedence": "not_applicable", + "data_type": "stable string identifier", + "definition": "The `capability_delta_manifest_v1` join ID selects two cells only when mode, scope, cache state, host, benchmark contract, and correctness contract are canonically equal, both capability manifests are complete, and at least one effective capability differs; it never authorizes a cross-implementation speed ratio.", + "deprecated_replacement": null, + "display_name": "Capability Delta Manifest Join v1", + "examples": [], + "introduced_version": "1.1.0", + "kind": "join_id", + "missing_or_unsupported_behavior": "classify the pair as not eligible when required equal fields differ or either capability manifest is incomplete", + "source_anchors": [ + "benchmarks/schema/comparisons-v1.schema.json", + "benchmarks/fact_comparisons.py" + ], + "status": "existing", + "term_id": "capability_delta_manifest_v1", + "unit": "not_applicable" + }, + { + "aggregation_rule": "apply to all recorded elapsed_ms values for one step ID in one exact cell group", + "allowed_values_or_range": "exactly `median_elapsed_ms_v1`", + "boundary_semantics": "uses each source occurrence's registered monotonic start and end boundaries", + "capability_or_freshness_implications": "none beyond the enclosing cell manifest", + "clock_or_cpu_scope": "wall time for each named occurrence", + "concurrency_rule": "does not sum overlapping occurrences; it takes the median of the selected occurrence durations", + "configuration_precedence": "not_applicable", + "data_type": "stable string identifier", + "definition": "The `median_elapsed_ms_v1` formula ID sorts the selected elapsed_ms values and returns the middle value for an odd count or the arithmetic mean of the two middle values for an even count.", + "deprecated_replacement": null, + "display_name": "Median Elapsed Milliseconds Formula v1", + "examples": [], + "introduced_version": "1.1.0", + "kind": "formula_id", + "missing_or_unsupported_behavior": "omit the aggregate when no numeric elapsed_ms occurrence is recorded", + "source_anchors": [ + "benchmarks/schema/comparisons-v1.schema.json", + "benchmarks/fact_comparisons.py" + ], + "status": "existing", + "term_id": "median_elapsed_ms_v1", + "unit": "milliseconds" + }, + { + "aggregation_rule": "divide the left cell's median_elapsed_ms_v1 result by the right cell's median_elapsed_ms_v1 result for the same step ID", + "allowed_values_or_range": "exactly `left_elapsed_divided_by_right_elapsed_v1`", + "boundary_semantics": "inherits the source occurrences used by both median operands", + "capability_or_freshness_implications": "valid only for a parity_manifest_and_contract_v1 join", + "clock_or_cpu_scope": "ratio of wall-time medians", + "concurrency_rule": "does not sum component durations; both operands preserve their recorded occurrence boundaries", + "configuration_precedence": "not_applicable", + "data_type": "stable string identifier", + "definition": "The `left_elapsed_divided_by_right_elapsed_v1` formula ID divides the left cell's median elapsed milliseconds by the right cell's median elapsed milliseconds for the same step ID; values above 1 mean the left cell took longer.", + "deprecated_replacement": null, + "display_name": "Left/Right Elapsed Ratio Formula v1", + "examples": [], + "introduced_version": "1.1.0", + "kind": "formula_id", + "missing_or_unsupported_behavior": "emit null when the right median is zero and emit no ratio unless the pair passed the parity join", + "source_anchors": [ + "benchmarks/schema/comparisons-v1.schema.json", + "benchmarks/fact_comparisons.py" + ], + "status": "existing", + "term_id": "left_elapsed_divided_by_right_elapsed_v1", + "unit": "dimensionless" + } + ], + "schema_version": 1, + "step_id_order": [ + "startup", + "project_discovery", + "change_classification", + "parse_extract", + "exact_delta", + "semantic_vectors", + "semantic_lsh", + "semantic_pairs", + "graph_publish_delete", + "graph_publish_upsert", + "graph_publish_indexes", + "dependency_discovery", + "dependency_package_index", + "pagerank", + "linkrank", + "first_core_query", + "first_all_fresh_query" + ], + "terminology_version": "1.1.0" +} diff --git a/cmd/codebase-memory-mcp/assets/skills/codebase-memory-exploring/SKILL.md b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-exploring/SKILL.md new file mode 100644 index 000000000..e0904efe4 --- /dev/null +++ b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-exploring/SKILL.md @@ -0,0 +1,92 @@ +--- +name: codebase-memory-exploring +description: > + This skill should be used when the user asks to "explore the codebase", + "understand the architecture", "what functions exist", "show me the structure", + "how is the code organized", "find functions matching", "search for classes", + "list all routes", "show API endpoints", or needs codebase orientation. +--- + +# Codebase Exploration via Knowledge Graph + +Use graph tools for structural code questions. They return scoped graph results instead of broad file-search output. + +## Workflow + +### Step 1: Check if project is indexed + +``` +list_projects +``` + +If the project is missing from the list: + +``` +index_repository(repo_path="/path/to/project") +``` + +If already indexed, skip manual indexing unless you need an immediate refresh; auto-sync can refresh the graph when configured. + +### Step 2: Get a structural overview + +``` +get_graph_schema +search_graph(mode="summary") # aggregate counts by label and file (top 20) +``` + +`get_graph_schema` returns node/edge counts and relationship patterns. `mode=summary` on `search_graph` gives aggregate counts by label type and top 20 files — useful for understanding codebase scope before drilling down. + +### Step 3: Find specific code elements + +Find functions by name pattern: +``` +search_graph(label="Function", name_pattern=".*Handler.*") +``` + +Find classes: +``` +search_graph(label="Class", name_pattern=".*Service.*") +``` + +Find all REST routes: +``` +search_graph(label="Route") +``` + +Find modules/packages: +``` +search_graph(label="Module") +``` + +Scope to a specific directory: +``` +search_graph(label="Function", qn_pattern=".*services\\.order\\..*") +``` + +### Step 4: Read source code + +After finding a function via search, read its source: +``` +get_code_snippet(qualified_name="project.path.to.FunctionName") +``` + +### Step 5: Understand structure + +For file/directory exploration within the indexed project: +``` +list_directory(path="src/services") +``` + +## When to Use Grep Instead + +- Searching for **string literals** or error messages → `search_code` or Grep +- Finding a file by exact name → Glob +- The graph doesn't index text content, only structural elements + +## Key Tips + +- Results default to 50 per page. Check `has_more` and use `offset` to paginate. Use `pagination_hint` in the response for next page. +- Use `compact=true` on `search_graph` to reduce token usage by omitting redundant `name` fields. +- Use `project` parameter when multiple repos are indexed. +- Route nodes have a `properties.handler` field with the actual handler function name. +- `exclude_labels` removes noise (e.g., `exclude_labels=["Route"]` when searching by name pattern). diff --git a/cmd/codebase-memory-mcp/assets/skills/codebase-memory-quality/SKILL.md b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-quality/SKILL.md new file mode 100644 index 000000000..f213cab2f --- /dev/null +++ b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-quality/SKILL.md @@ -0,0 +1,90 @@ +--- +name: codebase-memory-quality +description: > + This skill should be used when the user asks about "dead code", + "find dead code", "detect dead code", "show dead code", "dead code analysis", + "unused functions", "find unused functions", "unreachable code", + "identify high fan-out functions", "find complex functions", + "code quality audit", "find functions nobody calls", + "reduce codebase size", "refactor candidates", "cleanup candidates", + or needs code quality analysis. +--- + +# Code Quality Analysis via Knowledge Graph + +Use graph degree filtering to find dead code, high-complexity functions, and refactor candidates — all in single tool calls. + +## Workflow + +### Dead Code Detection + +Find likely isolated functions with zero CALLS degree, excluding entry points: + +``` +search_graph( + label="Function", + relationship="CALLS", + max_degree=0, + exclude_entry_points=true +) +``` + +`exclude_entry_points=true` removes route handlers, `main()`, and framework-registered functions that have zero callers by design. + +### Verify Dead Code Candidates + +Before deleting, verify each candidate truly has no callers: + +``` +trace_path(function_name="SuspectFunction", direction="inbound", depth=1) +``` + +Also check for read references (callbacks, stored in variables): + +``` +query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'SuspectFunction' RETURN a.name, a.file_path LIMIT 10") +``` + +### High Fan-Out Functions (calling 10+ others) + +These are often doing too much and are refactor candidates: + +``` +query_graph(query="MATCH (f)-[:CALLS]->(g) RETURN f.name, count(g) AS out_degree ORDER BY out_degree DESC LIMIT 20") +``` + +### High Fan-In Functions (called by 10+ others) + +These are critical functions — changes have wide impact: + +``` +query_graph(query="MATCH (f)<-[:CALLS]-(g) RETURN f.name, count(g) AS in_degree ORDER BY in_degree DESC LIMIT 20") +``` + +### Files That Change Together (Hidden Coupling) + +Find files with high git change coupling: + +``` +query_graph(query="MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score, r.co_change_count ORDER BY r.coupling_score DESC LIMIT 20") +``` + +High coupling between unrelated files suggests hidden dependencies. + +### Unused Imports + +``` +search_graph( + relationship="IMPORTS", + max_degree=0, + label="Module" +) +``` + +## Key Tips + +- `search_graph` defaults to 50 results per page. Use `limit` for more, or `mode=summary` to see total counts first. +- Use `compact=true` on `search_graph` to reduce token usage in dead code results. +- Use `file_pattern` to scope analysis to specific directories: `file_pattern="**/services/**"`. +- Dead code detection works best after a full index — run `index_repository` if the project was recently set up. +- Paginate results with `limit` and `offset` — check `has_more` and `pagination_hint` in the response. diff --git a/cmd/codebase-memory-mcp/assets/skills/codebase-memory-reference/SKILL.md b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-reference/SKILL.md new file mode 100644 index 000000000..d22b6472e --- /dev/null +++ b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-reference/SKILL.md @@ -0,0 +1,176 @@ +--- +name: codebase-memory-reference +description: > + This skill should be used when the user asks about "codebase-memory-mcp tools", + "graph query syntax", "Cypher query examples", "edge types", + "how to use search_graph", "query_graph examples", or needs reference + documentation for the codebase knowledge graph tools. +--- + +# Codebase Memory MCP — Tool Reference + +## Tools + +| Tool | Purpose | +|------|---------| +| `index_repository` | Parse and ingest repo into graph; auto-sync can refresh it when configured | +| `index_status` | Check indexing status (ready/indexing/not found) | +| `list_projects` | List all indexed projects with timestamps and counts | +| `delete_project` | Remove a project from the graph | +| `search_graph` | Structured search with filters (name, label, degree, file pattern). Supports `mode=summary` for aggregate counts, `compact=true` to reduce tokens. | +| `search_code` | Grep-like text search within indexed project files | +| `trace_path` | BFS call chain traversal. Supports `risk_labels=true`, `compact=true`, `max_results`. | +| `detect_changes` | Map git diff to affected symbols + blast radius with risk scoring | +| `query_graph` | Cypher-like graph queries. Output capped at `max_output_bytes` (default 32KB). | +| `get_graph_schema` | Node/edge counts, relationship patterns | +| `get_code_snippet` | Read source code by qualified name. Supports `mode=signature` (API only) and `mode=head_tail` (preserve start+end). | +| `get_architecture` | Architecture summary, clusters, routes, dependencies, and key functions | +| `manage_adr` | Read or update Architecture Decision Records | +| `ingest_traces` | Ingest OpenTelemetry traces to validate HTTP_CALLS edges | +| `index_dependencies` | Index local dependency source under `{project}.dep.{name}` | + +## Edge Types + +| Type | Meaning | +|------|---------| +| `CALLS` | Direct function call within same service | +| `HTTP_CALLS` | Synchronous cross-service HTTP request | +| `ASYNC_CALLS` | Async dispatch (Cloud Tasks, Pub/Sub, SQS, Kafka) | +| `IMPORTS` | Module/package import | +| `DEFINES` / `DEFINES_METHOD` | Module/class defines a function/method | +| `HANDLES` | Route node handled by a function | +| `IMPLEMENTS` | Type implements an interface | +| `OVERRIDE` | Struct method overrides an interface method | +| `USAGE` | Read reference (callback, variable assignment) | +| `FILE_CHANGES_WITH` | Git history change coupling | +| `CONTAINS_FILE` / `CONTAINS_FOLDER` / `CONTAINS_PACKAGE` | Structural containment | + +## Node Labels + +`Project`, `Package`, `Folder`, `File`, `Module`, `Class`, `Function`, `Method`, `Interface`, `Enum`, `Type`, `Route` + +## Qualified Name Format + +`..` — file path with `/` replaced by `.`, extension removed. + +Examples: +- `myproject.cmd.server.main.HandleRequest` (Go) +- `myproject.services.orders.ProcessOrder` (Python) +- `myproject.src.components.App.App` (TypeScript) + +Use `search_graph` to discover qualified names, then pass them to `get_code_snippet`. + +## Cypher Subset (for query_graph) + +**Supported:** +- `MATCH` with node labels and relationship types +- Variable-length paths: `-[:CALLS*1..3]->` +- `WHERE` with `=`, `<>`, `>`, `<`, `>=`, `<=`, `=~` (regex), `CONTAINS`, `STARTS WITH` +- `WHERE` with `AND`, `OR`, `NOT` +- `RETURN` with property access, `COUNT(x)`, `DISTINCT` +- `ORDER BY` with `ASC`/`DESC` +- `LIMIT` +- Edge property access: `r.confidence`, `r.url_path`, `r.coupling_score` + +**Not supported:** `WITH`, `COLLECT`, `SUM`, `CREATE/DELETE/SET`, `OPTIONAL MATCH`, `UNION` + +## Common Cypher Patterns + +``` +# Cross-service HTTP calls with confidence +MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 20 + +# Filter by URL path +MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name + +# Interface implementations +MATCH (s)-[r:OVERRIDE]->(i) RETURN s.name, i.name LIMIT 20 + +# Change coupling +MATCH (a)-[r:FILE_CHANGES_WITH]->(b) WHERE r.coupling_score >= 0.5 RETURN a.name, b.name, r.coupling_score + +# Functions calling a specific function +MATCH (f:Function)-[:CALLS]->(g:Function) WHERE g.name = 'ProcessOrder' RETURN f.name LIMIT 20 +``` + +## Regex-Powered Search (No Full-Text Index Needed) + +`search_graph` and `search_code` support full Go regex, making full-text search indexes unnecessary. Regex patterns provide precise, composable queries that cover all common discovery scenarios: + +### search_graph — name_pattern / qn_pattern + +| Pattern | Matches | Use case | +|---------|---------|----------| +| `.*Handler$` | names ending in Handler | Find all handlers | +| `(?i)auth` | case-insensitive "auth" | Find auth-related symbols | +| `get\|fetch\|load` | any of three words | Find data-loading functions | +| `^on[A-Z]` | names starting with on + uppercase | Find event handlers | +| `.*Service.*Impl` | Service...Impl pattern | Find service implementations | +| `^(Get\|Set\|Delete)` | CRUD prefixes | Find CRUD operations | +| `.*_test$` | names ending in _test | Find test functions | +| `.*\\.controllers\\..*` | qn_pattern for directory scoping | Scope to controllers dir | + +### search_code — regex=true + +| Pattern | Matches | Use case | +|---------|---------|----------| +| `TODO\|FIXME\|HACK` | multi-pattern scan | Find tech debt markers | +| `(?i)password\|secret\|token` | case-insensitive secrets | Security scan | +| `func\\s+Test` | Go test functions | Find test entry points | +| `api[._/]v[0-9]` | API version references | Find versioned API usage | +| `import.*from ['"]@` | scoped npm imports | Find package imports | + +### Combining Filters for Surgical Queries + +``` +# Find unused auth handlers +search_graph(name_pattern="(?i).*auth.*handler.*", max_degree=0, exclude_entry_points=true) + +# Find high fan-out functions in the services directory +query_graph(query="MATCH (f)-[:CALLS]->(g) WHERE f.qualified_name =~ '.*\\.services\\..*' RETURN f.name, count(g) AS out_degree ORDER BY out_degree DESC LIMIT 20") + +# Find all route handlers matching a URL pattern +search_code(pattern="(?i)(POST|PUT).*\\/api\\/v[0-9]\\/orders", regex=true) +``` + +## Token Reduction Parameters + +These parameters reduce response size (tokens) without affecting indexed data: + +| Parameter | Tool | Effect | +|-----------|------|--------| +| `mode="summary"` | `search_graph` | Return aggregate counts by label/file instead of individual results (~99% reduction) | +| `mode="signature"` | `get_code_snippet` | Return only function signature, params, return type (~99% reduction) | +| `mode="head_tail"` | `get_code_snippet` | Return first 60% + last 40% of lines, preserving signature and return/cleanup | +| `compact=true` | `search_graph`, `trace_path` | Omit `name` field when redundant with `qualified_name` (~15-25% reduction) | +| `max_lines=N` | `get_code_snippet` | Cap source lines (default 200, set 0 for unlimited) | +| `max_output_bytes=N` | `query_graph` | Cap response bytes (default 32KB, set 0 for unlimited) | +| `max_results=N` | `trace_path` | Cap BFS results per direction (default 25) | + +All defaults are configurable via `codebase-memory-mcp config set `: +`search_limit`, `snippet_max_lines`, `trace_max_results`, `query_max_output_bytes`. + +## Critical Pitfalls + +1. **`search_graph(relationship="HTTP_CALLS")` does NOT return edges** — it filters nodes by degree. Use `query_graph` with Cypher to see actual edges. +2. **`query_graph` output is capped at 32KB by default** — add LIMIT to your Cypher query or set `max_output_bytes=0` for unlimited. +3. **`trace_path` works best with exact names** — use `search_graph(name_pattern=".*Partial.*")` first to discover names. +4. **`direction="outbound"` misses cross-service callers** — use `direction="both"` for full context. +5. **`search_graph` defaults to 50 results** — use `limit` parameter for more, or `mode=summary` to see total counts first. + +## Decision Matrix + +| Question | Use | +|----------|-----| +| Who calls X? | `trace_path(direction="inbound")` | +| What does X call? | `trace_path(direction="outbound")` | +| Full call context | `trace_path(direction="both")` | +| Find by name pattern | `search_graph(name_pattern="...")` | +| Dead code | `search_graph(max_degree=0, exclude_entry_points=true)` | +| Cross-service edges | `query_graph` with Cypher | +| Impact of local changes | `detect_changes()` | +| Risk-classified trace | `trace_path(risk_labels=true)` | +| Text search | `search_code` or Grep | +| Quick codebase overview | `search_graph(mode="summary")` | +| Function API only | `get_code_snippet(mode="signature")` | +| Large function safely | `get_code_snippet(mode="head_tail")` | diff --git a/cmd/codebase-memory-mcp/assets/skills/codebase-memory-tracing/SKILL.md b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-tracing/SKILL.md new file mode 100644 index 000000000..532940f8a --- /dev/null +++ b/cmd/codebase-memory-mcp/assets/skills/codebase-memory-tracing/SKILL.md @@ -0,0 +1,127 @@ +--- +name: codebase-memory-tracing +description: > + This skill should be used when the user asks "who calls this function", + "what does X call", "trace the call chain", "find callers of", + "show dependencies", "what depends on", "trace call path", + "find all references to", "impact analysis", or needs to understand + function call relationships and dependency chains. +--- + +# Call Chain Tracing via Knowledge Graph + +Use graph tools to trace function call relationships. One `trace_path` call replaces dozens of grep searches across files. + +## Workflow + +### Step 1: Discover the exact function name + +`trace_path` works best with an exact name. If you don't know the exact name, discover it first with regex: + +``` +search_graph(name_pattern=".*Order.*", label="Function") +``` + +Use full regex for precise discovery — no full-text search needed: +- `(?i)order` — case-insensitive +- `^(Get|Set|Delete)Order` — CRUD variants +- `.*Order.*Handler$` — handlers only +- `qn_pattern=".*services\\.order\\..*"` — scope to order service directory + +This returns matching functions with their qualified names and file locations. + +### Step 2: Trace callers (who calls this function?) + +``` +trace_path(function_name="ProcessOrder", direction="inbound", depth=3) +``` + +Returns a hop-by-hop list of all functions that call `ProcessOrder`, up to 3 levels deep. + +### Step 3: Trace callees (what does this function call?) + +``` +trace_path(function_name="ProcessOrder", direction="outbound", depth=3) +``` + +### Step 4: Full context (both callers and callees) + +``` +trace_path(function_name="ProcessOrder", direction="both", depth=3) +``` + +**Use `direction="both"` for complete context.** Cross-service HTTP_CALLS edges from other services appear as inbound edges — `direction="outbound"` alone misses them. + +### Step 5: Read suspicious code + +After finding interesting callers/callees, read their source: + +``` +get_code_snippet(qualified_name="project.path.module.FunctionName") +get_code_snippet(qualified_name="project.path.module.FunctionName", mode="signature") # API only, saves tokens +``` + +## Cross-Service HTTP Calls + +To see all HTTP links between services with URLs and confidence scores: + +``` +query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence ORDER BY r.confidence DESC LIMIT 20") +``` + +Filter by URL path: +``` +query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS '/orders' RETURN a.name, b.name, r.url_path") +``` + +## Async Dispatch (Cloud Tasks, Pub/Sub, etc.) + +Find dispatch functions by name pattern, then trace: +``` +search_graph(name_pattern=".*CreateTask.*|.*send_to_pubsub.*") +trace_path(function_name="CreateMultidataTask", direction="both") +``` + +## Interface Implementations + +Find which structs implement an interface method: +``` +query_graph(query="MATCH (s)-[r:OVERRIDE]->(i) WHERE i.name = 'Read' RETURN s.name, i.name LIMIT 20") +``` + +## Read References (callbacks, variable assignments) + +``` +query_graph(query="MATCH (a)-[r:USAGE]->(b) WHERE b.name = 'ProcessOrder' RETURN a.name, a.file_path LIMIT 20") +``` + +## Risk-Classified Impact Analysis + +Add `risk_labels=true` to get risk classification on each node: + +``` +trace_path(function_name="ProcessOrder", direction="inbound", depth=3, risk_labels=true) +``` + +Returns nodes with `risk` (CRITICAL/HIGH/MEDIUM/LOW) based on hop depth, plus an `impact_summary` with counts. Risk mapping: hop 1=CRITICAL, 2=HIGH, 3=MEDIUM, 4+=LOW. + +## Detect Changes (Git Diff Impact) + +Map uncommitted changes to affected symbols and their blast radius: + +``` +detect_changes() +detect_changes(scope="staged") +detect_changes(scope="branch", base_branch="main") +``` + +Returns changed files, changed symbols, and impacted callers with risk classification. Scopes: `unstaged`, `staged`, `all` (default), `branch`. + +## Key Tips + +- Start with `depth=1` for quick answers, increase only if needed (max 5). +- Edge types in trace results: `CALLS` (direct), `HTTP_CALLS` (cross-service), `ASYNC_CALLS` (async dispatch), `USAGE` (read reference), `OVERRIDE` (interface implementation). +- `search_graph(relationship="HTTP_CALLS")` filters nodes by degree — it does NOT return edges. Use `query_graph` with Cypher to see actual edges with properties. +- Default `max_results=25` per direction (configurable). Use `max_results=100` for exhaustive traces. +- Use `compact=true` on `trace_path` to reduce token usage by omitting redundant `name` fields. +- `detect_changes` requires git in PATH. diff --git a/docs/BENCHMARK_CAMPAIGN.md b/docs/BENCHMARK_CAMPAIGN.md new file mode 100644 index 000000000..6b220690e --- /dev/null +++ b/docs/BENCHMARK_CAMPAIGN.md @@ -0,0 +1,13 @@ +# Legacy benchmark documentation path + +This document moved to [`docs/BENCHMARK_EXPERIMENTS.md`](BENCHMARK_EXPERIMENTS.md). + +New documentation and interfaces use "experiment". Existing runsets remain readable: + +- `--campaign-root` still works as an alias for `--experiment-root`. +- Automatic runs retain `.worktrees/benchmark-campaign/` so old results resume. +- Retained `scripts/benchmark-incremental-speed.py` plan entries resolve to + `benchmarks/run_benchmark.py` when the recorded path no longer exists. + +This file is kept as a short pointer stub (rather than deleted) so existing links +and bookmarks to `docs/BENCHMARK_CAMPAIGN.md` keep resolving. diff --git a/docs/BENCHMARK_EXPERIMENTS.md b/docs/BENCHMARK_EXPERIMENTS.md new file mode 100644 index 000000000..63151daa1 --- /dev/null +++ b/docs/BENCHMARK_EXPERIMENTS.md @@ -0,0 +1,723 @@ +# Reproducible benchmark experiments + +`benchmarks/run_experiments.py` runs a JSON plan sequentially and keeps every +attempt under a content-addressed cell directory. It is intended for release-build +comparisons where correctness and query-result quality are gates, not optional +context around a speed claim. New automation uses this entry point and +`--experiment-root`. When retained plan or command records name a retired benchmark +script path, the loader resolves that path to the canonical entry point; no executable +compatibility wrapper is installed. Legacy flag aliases, persisted JSON keys, and the +`.worktrees/benchmark-campaign/` location remain readable for retained runs. +Retained plans may also name the removed `optional_graph_disabled` profile; it resolves +to the same capability manifest as `minimal_indexing`, which is the only one new +automatic plans emit. + +Use a durable ignored experiment root. Automatic runs continue to use +`.worktrees/benchmark-campaign/` so existing retained runsets resume in place. +The runner rejects the operating-system temporary tree by default because a crash +or reboot can otherwise erase manifests, results, and logs. Do not track generated +results or the generated Markdown report in Git. + +## Repository layout + +Branch-created benchmark code, active schemas, terminology, configuration data, and +fixtures live under `benchmarks/`. The three benchmark shell scripts inherited from +upstream remain at their established `scripts/` paths. Human-facing guides remain +under `docs/`, tests under `tests/`, and the generated profiling header under `src/` +because those files belong to their respective integration surfaces. + +New commands and source anchors use `benchmarks/`. The frozen +`docs/schema/benchmark-facts-v1.schema.json` remains at its original URI because +retained v1 bundles embed that exact identifier. The loaders accept the former v2 +schema URI and recorded terminology hash, while new bundles emit only the canonical +`benchmarks/schema/facts-v2.schema.json` URI. The experiment runner resolves the +retired single-run script path in retained plans without shipping a duplicate +entry point. + +## Cell identity + +A cell ID is the first 24 hexadecimal characters of the SHA-256 of these canonical +JSON fields: + +- full revision and binary SHA-256; +- build metadata, including the compiler and optimization flags; +- capability configuration; +- transport, scenario, repetition, and harness version; +- command, working directory, environment overrides, timeout, and accepted exit codes. + +Changing any of those inputs creates a different cell. A completed cell is resumed +only when its completion marker, retained result, result SHA-256, binary SHA-256, +current plan identity, and every archived artifact path, size, and SHA-256 agree. + +## Canonical fact tables + +Every new benchmark result also writes a schema-valid `facts.json` bundle, normalized +`runs.json`, `steps.jsonl`, `results.json`, and `artifacts.json` tables, and a hashed +`manifest.json` under the experiment attempt's artifact directory. Standalone runs use +`--facts-dir DIR`; when only `--out result.json` is given, facts default to +`result.facts/`. New bundles use `benchmarks/schema/facts-v2.schema.json`; +the retained v1 schema remains available for earlier runsets. The +canonical benchmark vocabulary is `benchmarks/terminology.json`; its generated +human-readable view is [BENCHMARK_TERMINOLOGY.md](BENCHMARK_TERMINOLOGY.md). +`uv run python benchmarks/run_benchmark.py --describe-terms +json|markdown` prints either view without requiring a benchmark binary. + +The run row records the experiment cell ID and label, exact candidate commit, +repetition, binary path/hash/size, build metadata, harness hash, host metadata, +capability arguments, workload scope, and per-layer cache knowledge. Step rows keep +each occurrence separate and distinguish elapsed work from CPU, queue, worker, +dependency, and monotonic-boundary fields. A field absent from the historical +measurement is an explicit `{"status":"unknown","reason":"..."}` value; it is +never reconstructed from a preset name or treated as suitable for a parity join. +Every fact bundle records the terminology version, canonical-content SHA-256, and +benchmark-generator SHA-256. A retained bundle therefore identifies the exact +definitions and normalizer that gave each field and step ID its meaning. +Experiment cells supply the candidate commit and build metadata automatically. +Standalone runs must pass `--candidate-revision FULL_COMMIT` and +`--build-metadata-json '{...}'` to make those fields authoritative; otherwise the +current checkout HEAD is retained separately as measurement context and the binary's +source revision/build flags remain `unknown`. + +When every completed experiment cell has a canonical fact bundle, report generation +also writes `*.comparisons.json` and `*.fact-appendix.md`. The JSON conforms to +`benchmarks/schema/comparisons-v1.schema.json` and retains the source bundle, +run, and occurrence IDs behind every derived row. It classifies each cell pair as: + +- `parity_comparison`: identical mode, complete effective capabilities, scope, + cache state, host, benchmark contract, and correctness contract, with no unknown + required value. Only this class may report a cross-implementation elapsed-time + ratio. +- `capability_delta_comparison`: identical mode, scope, cache state, host, benchmark + contract, and correctness contract, but explicitly different complete capability + manifests. It reports the differences and no speed ratio. +- `not_eligible`: a required equality failed or required evidence is incomplete or + unknown. The record states each rejection reason. + +The emitted `join_id` and `formula_id` values have normative definitions in the +terminology registry. Lifecycle tables select recorded outer lifecycle occurrences; +they never construct wall time by summing child spans that may overlap or execute on +different threads or processes. Reports for retained runs that predate fact bundles +still render, but state that fact-derived comparisons are unavailable. + +Retained reports from earlier harness versions remain usable: + +```bash +uv run python benchmarks/run_benchmark.py \ + --import-report path/to/result.json \ + --facts-dir path/to/recovered-facts +``` + +The importer recovers binary identity, recorded configuration, elapsed phases, +peak RSS, outcomes, and retained-log hashes when present. It preserves missing +revision, build, cache, CPU, timestamp, worker, and concurrency evidence as +`unknown`, so generated comparisons can state the historical limitation rather +than silently inventing parity. + +### Fact vocabulary and timing rules + +These terms are normative in the runner, schema, JSON tables, and generated reports: + +| Term | Meaning | +|---|---| +| Experiment | One declared comparison design: its candidates, capabilities, workloads, transports, repetitions, and execution order. | +| Runset | The immutable experiment specification identified by the first 12 hexadecimal characters of its canonical JSON SHA-256. Reusing identical semantic inputs resumes the same runset. | +| Cell | One fully resolved point in the experiment matrix, including one candidate revision, binary, build, capability map, workload, transport, and repetition. | +| Attempt | One process execution of a cell. Failed or interrupted attempts remain evidence; only a validated attempt creates `complete.json`. | +| Lifecycle | The user-observable sequence represented by one `run_id`, from process invocation through the benchmark gate. Component steps may overlap within it. | +| Run row | Identity and conditions shared by every observation in one lifecycle: implementation, harness, host, capability, scope, and cache facts. | +| Step row | One measured operation occurrence. `step_id` names the operation class; `occurrence_id` identifies this occurrence, so repeated operations are never merged. | +| Parent occurrence | A containment relation in the recorded operation hierarchy. It does not prove that parent and child executed serially. | +| Dependency occurrence | A measured predecessor that must finish before the step can proceed. An empty list means no dependency edge was recorded, not that the step was independent. | +| `elapsed_ms` | Wall-clock duration of that occurrence. Overlapping parent, child, or sibling durations must not be summed. | +| `cpu_ms` | CPU time consumed by the named `cpu_scope`. It remains `unknown` when the profiler recorded only wall time. | +| `queue_wait_ms` | Time after the operation became runnable but before its worker began executing. It remains `unknown` without scheduler instrumentation. | +| Critical path | The dependency-chain duration that determines lifecycle wall time. It remains `unknown` unless timestamped dependency events make the chain recoverable. | +| Result row | A correctness, quality, or instrumentation outcome. Product-contract failures and harness failures use different `kind` values. | +| Artifact row | A retained file identified by path, byte count when known, and SHA-256. | +| Unknown fact | `{"status":"unknown","reason":"..."}`: the source did not record the value. Unknown values prohibit capability-parity joins and arithmetic. | + +`timing_components_ms` values parsed from existing worker profile markers become +separate step occurrences. The current markers provide elapsed wall time and +containment but not start/end timestamps, worker IDs, CPU time, queue wait, or a +dependency event graph; those fields therefore remain explicitly `unknown`. +This preserves parallel implementations without pretending their overlapping work +was serial. Future low-overhead instrumentation can populate the same fields without +changing the fact-table contract. + +For index results, `elapsed_ms` is the user-visible tool-call boundary. +`worker_elapsed_ms` is the supervised worker lifetime when that marker exists, and +`process_overhead_ms` is the non-negative difference between those two recorded +boundaries. `indexed_work_elapsed_ms` is the narrower full or incremental pipeline +marker inside the worker. These are nested observations: do not add worker and +indexed-work durations to the user lifecycle, and do not describe +`process_overhead_ms` as indexing algorithm time. + +## Plan format + +```json +{ + "schema_version": 1, + "cells": [ + { + "label": "final-defaults", + "revision": "0123456789abcdef0123456789abcdef01234567", + "binary_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "build": { + "target": "make -f Makefile.cbm cbm", + "compiler": "Apple clang 17.0.0", + "cflags": "-O2 -DCBM_BIND_TS_ALLOCATOR=1" + }, + "capabilities": {}, + "transport": "mcp", + "scenario": "self_dogfood", + "repetition": 1, + "harness_version": "run_benchmark.py:", + "cwd": "/absolute/path/to/codebase-memory-mcp", + "command": [ + "uv", "run", "python", "benchmarks/run_benchmark.py", + "--binary", "/absolute/path/to/release-binary", + "--self-dogfood", "--repo-root", "/absolute/path/to/codebase-memory-mcp", + "--transport", "mcp", "--out", "{result_path}" + ], + "accepted_exit_codes": [0, 1], + "timeout_seconds": 3600 + } + ] +} +``` + +Exit code `1` is explicit in this example because the benchmark harness uses it for +a valid measurement that fails a quality or performance gate. The experiment runner +still requires a parseable result whose `binary_metadata.sha256` matches the plan. +Crashes, timeouts, other exit codes, missing results, and mismatched binaries remain +failed attempts and never receive `complete.json`. + +For compact `--matrix-spec` grids, each scenario requires `frontier_files` and +`exact_caps` arrays. Use a positive integer cap for an explicit cap sweep. Use +`null` to preserve each candidate's configured/default +`incremental_exact_max_affected_paths`; the generated cell is labelled +`capdefault` and does not inject a config override. + +### Reusable ref-based matrices + +A compact matrix may name arbitrary Git refs instead of embedding candidate binary +paths. This is the preferred long-lived interface for comparing new branches, +capabilities, configuration values, worker counts, memory budgets, and workload +flags after the built-in dated presets become irrelevant: + +```json +{ + "schema_version": 1, + "identity_version": 2, + "harness_version": "development-comparison-v1", + "benchmark_script": "benchmarks/run_benchmark.py", + "cwd": ".", + "repetitions": 3, + "execution_order": "paired_interleaved", + "transports": ["mcp"], + "build_environment": { + "CC": "clang", + "CXX": "clang++" + }, + "candidates": [ + { + "label": "baseline", + "ref": "main", + "capability_support": {"rank": true, "dependencies": true} + }, + { + "label": "candidate", + "ref": "feature/new-design", + "capability_support": {"rank": true, "dependencies": true} + } + ], + "profiles": [ + { + "label": "default-workers", + "config_profile": "candidate_native_configuration", + "capabilities": {} + }, + { + "label": "four-workers", + "config_profile": "candidate_native_configuration", + "capabilities": {}, + "product_environment": {"CBM_WORKERS": "4"}, + "benchmark_args": ["--overhead-probes", "3"] + } + ], + "scenarios": [ + { + "name": "go_modify_1", + "frontier_files": [4, 64], + "exact_caps": [null] + } + ] +} +``` + +Run it from a repository checkout: + +```sh +uv run python benchmarks/run_experiments.py \ + --matrix-spec /absolute/path/development-comparison.json \ + --experiment-root /durable/ignored/path/development-comparison +``` + +The source spec is archived by SHA-256. Each `ref` is resolved to a full commit, +built in a detached worktree, and replaced in a separate resolved spec by the +existing `revision`, `binary`, `binary_sha256`, compiler, flags, tree, and commit +metadata. The source object is not modified. A ref entry cannot also claim a +prebuilt binary or revision. + +Use the existing axes rather than adding branch-specific code: + +| Need | Matrix field | Behavior | +|---|---|---| +| Shared compiler/diagnostic settings | `build_environment` | Allowlisted `CC`, `CXX`, `EXTRA_CFLAGS`, and `EXTRA_CXXFLAGS`; passed as environment and argv-safe Make overrides to probes, clean builds, and production builds, then retained beside the effective C/C++ compiler identities and expanded production flags | +| Product configuration | `config_overrides` | Passed through the versioned config-spelling compatibility path | +| Process/resource knob | `product_environment` | Explicit `CBM_*` variables only; inherited product variables remain removed | +| New optional benchmark workload flag | `benchmark_args` | Additive arguments; experiment-owned identity, output, transport, config, and scenario flags are rejected | +| Candidate compatibility | `capability_support` | Records which correctness gates apply without pretending unsupported features exist | +| Branch-specific ablation | `candidate_labels` on a profile | Applies a profile only to named compatible candidates | + +Top-level product environment is overridden by candidate, then profile, then +scenario values. Benchmark arguments are appended in that same order. +Top-level build environment is deliberately shared by every ref candidate so a +comparison cannot silently build candidates with different compiler settings. +`CBM_CACHE_DIR`, `CBM_PROFILE`, auto-index isolation, and run-context variables stay +harness-owned so a matrix cannot redirect live data or suppress measurement logs. +Fully resolved historical specs remain valid, and specs that omit these new fields +retain their previous cell shape and identity. + +Candidate source/build locations are execution settings rather than matrix +semantics. New worktrees use `/.worktrees/benchmark-candidates` unless +`--candidate-root` selects another writable primary. Repeat +`--candidate-search-root` to search moved existing roots in preferred order: + +```sh +uv run python benchmarks/run_experiments.py \ + --matrix-spec /absolute/path/development-comparison.json \ + --experiment-root /durable/ignored/path/development-comparison \ + --candidate-root /fast-storage/cbm-candidates \ + --candidate-search-root /archive/previous-candidates \ + --candidate-search-root /mounted/team-candidates +``` + +Only clean worktrees registered to the current Git repository and pinned to the +exact candidate commit are eligible. The runner creates worktrees, build logs, +and cache metadata only under the primary root. A selected existing worktree may +have its ordinary `build/` directory cleaned and rebuilt so the recorded compiler, +flags, and binary hash are trustworthy. A missing search root fails with its +resolved path instead of silently falling back. Resolved candidate records retain +the selected binary path and SHA-256, so moved-root reuse remains auditable without +embedding machine-specific paths in the reusable source matrix. + +### Container isolation + +`benchmarks/run_container_experiment.py` is a thin isolation coordinator around the +same `run_experiments.py` entry point. Use it when several exact builds must exercise +their real daemon-backed CLI or MCP paths without joining the host account's active +exact-build cohort: + +```sh +uv run python benchmarks/run_container_experiment.py \ + --matrix-spec /absolute/path/development-comparison.json \ + --experiment-root /durable/ignored/path/development-comparison \ + --cpus 4 \ + --memory 8g \ + --workers 4 \ + -- --minimum-free-gb 4 +``` + +The coordinator is intentionally smaller than the benchmark engine: + +1. It refuses tracked source changes and bundles exact `HEAD`, branch, tag, and + remote refs without moving them. Stash and other non-branch/tag/remote namespaces + are excluded from benchmark input. +2. It builds or identifies the digest-pinned + `test-infrastructure/Dockerfile` image and rejects emulated architectures. + Docker benchmark runs default to Clang 18.1.3; the image also retains GCC for + portability and explicit compiler-ablation cohorts. A custom matrix overrides + the default with a complete pair such as + `"build_environment": {"CC": "gcc", "CXX": "g++"}`. Resolved candidate + records retain the actual C/C++ compiler identities and flags. +3. It requires explicit CPU, memory, and worker budgets. Workers may not exceed the + container CPU budget. Candidate builds default to the complete declared CPU + capacity (`--cpus 16` selects `make -j16`); fractional budgets round up. + `--build-jobs N` provides an explicit positive override for memory-constrained + builds, and the resolved value is recorded in the run identity and manifest. +4. It copies the bundle and optional matrix spec into labeled Docker volumes. + The cloned repository retains the experiment runner's normal + `/.worktrees/benchmark-candidates` default. Candidate builds, fixture data, + caches, daemon state, plans, and reports stay off host bind mounts during + measurement. +5. It invokes the existing experiment runner with `CBM_WORKERS` as an explicit product + environment value. The shared `benchmarks/environment-policy-v1.json` registry + prevents that value from replacing cache, profiling, auto-index, or run-context + isolation. +6. It exports results through a staging directory. Existing history bytes may be + reused, but different bytes at an existing path fail loudly rather than being + overwritten. +7. It stores each container-environment record under the established `manifests/` + output directory with a content-derived suffix. Repeating identical bytes is + idempotent; changed arguments or timestamps retain a distinct audit record. +8. On a candidate-build failure, it exports the work volume's build logs to + `container-failures//build-logs/`. If Docker cannot export them, + the returned error names the retained volume and in-volume path for inspection. +9. It derives a stable repository snapshot identity from the source revision and + sorted Git ref/commit heads, independently of nondeterministic bundle pack bytes. + The 24-hexadecimal run key combines that snapshot with the effective matrix, + resource budget, and measurement arguments. The exact bundle SHA-256 remains in + the environment manifest. Each cohort runs under `runsets//`; + `--audit-only` deliberately retains the same key. Valid older cohorts therefore + remain reloadable without being confused with genuinely unplanned cell + directories in the current cohort. +10. It removes every transient coordinator and measured container in a `finally` + path. The two labeled volumes remain for resume and their exact names are printed. + +The experiment root is the human-selected history name; content-addressed source +specs, resolved specs, plans, cells, reports, environment snapshots, and binary +hashes remain under `runsets//`; container environment manifests remain +under the history's `manifests/` directory. +Rerunning the same source spec and root resumes completed cells. A failed candidate +still exports partial immutable evidence before the coordinator returns an error. + +After the export is verified and no resume is required, remove only the two exact +volume names printed by the coordinator: + +```sh +docker volume rm cbm-benchmark-work- +docker volume rm cbm-benchmark-results- +``` + +The coordinator never stops the Docker backend because that could disrupt unrelated +containers. After all benchmark work is complete, separately verify that no +`cbm-benchmark-*` container remains and stop Docker Desktop or the host Docker service +using the platform's normal administration command. + +Interpretation boundary: the container matrix is a controlled same-image, +same-resource relative comparison. Its Linux kernel, compiler, libc, Docker VM, and +storage environment differ from native macOS, so do not join absolute container +latencies or RSS values to native-host series. Use a small scheduled native +confirmation to establish whether the direction and ranking generalize. + +For a Clang-versus-GCC ablation, run two otherwise byte-identical named matrices +and change only the complete `CC`/`CXX` pair. Keep compiler comparisons in distinct +histories so they cannot masquerade as product-revision effects. The compiler +identity, expanded flags, source tree, and binary hash in each resolved candidate +provide the audit join. Do not add that compiler axis to an unrelated product +regression experiment. + +Set top-level `"accepted_exit_codes": [0, 1]` when the matrix benchmark uses exit +code 1 for a completed measurement that missed a correctness or quality gate. The +expanded cells retain that policy in their identities. Result parsing, binary-hash +validation, and the structured `error` check still prevent crashes or harness +errors from becoming completed evidence. + +Legacy compact specs retain their original grouped cell order and plan hashes. New +performance experiments should set top-level +`"execution_order": "paired_interleaved"`. That opt-in order executes every +candidate/profile cell for repetition 1 before repetition 2, and records the +repetition block plus absolute execution position in each cell identity. This reduces +alignment between one configuration and slow host drift while keeping heavy cells +strictly sequential. It is deterministic rather than randomly shuffled, so the plan +is exactly reproducible; reports must still retain raw order and variation. + +For isolated capability fixtures, set top-level `"capability_quality"` to `"rank"`, +`"dependencies"`, `"similarity"`, or `"semantic_edges"` and omit `scenarios`. +The runner expands candidate, profile, transport, and repetition axes without adding +incremental frontier arguments. Each command records the capability fixture and uses +`--include-logs`, while named config profiles provide matched enabled/disabled +ablations. Set top-level `"index_mode": "moderate"` or `"full"` for `similarity` +and `semantic_edges`; FAST mode intentionally does not generate either relationship. + +The semantic pair task set is content-addressed from its version, source hashes, +relationship, score property, and explicit positive/negative pair judgments. +`SIMILAR_TO` structural clones and `SEMANTICALLY_RELATED` control-flow variants are +separate cases because the semantic pass intentionally excludes pairs already above +the structural MinHash threshold. Pair reports retain TP/FP/FN/TN and witnesses, +precision, recall, F1, false-positive rate, per-category counts, raw query rows, +latency, bytes, and estimated tokens. Natural-repository pairs outside the explicit +judgment set are retained as `unjudged`; incomplete natural ground truth never turns +an unknown result into a false positive. + +For full-index, real-edit incremental, fresh-rebuild, query, response-size, and peak-RSS +measurements on one pinned repository, use `"workload": "self_dogfood"` with an exact +repository identity: + +```json +{ + "workload": "self_dogfood", + "repository_background": { + "repo": "/absolute/path/to/source-checkout", + "revision": "0123456789abcdef0123456789abcdef01234567", + "tree": "89abcdef0123456789abcdef0123456789abcdef" + }, + "scenarios": [{"name": "route_handler"}] +} +``` + +Each cell creates a detached worktree from the declared commit rather than mutable +`HEAD`. The plan identity retains the repository revision and tree, and result +validation rejects either mismatch. Use a scenario with an actual source edit when +making incremental-index claims; `noop` measures invocation overhead only. + +Older candidates may not expose configuration flags added by a newer branch. A profile +can therefore declare `"candidate_labels": ["latest"]` to restrict an ablation to +candidates that accept it. Keep an unrestricted default profile for every candidate, +and record fixed-default or unsupported capabilities in `capability_support`; do not +pass an unknown flag to an old binary or pretend that its default is an ablation. +The harness likewise leaves `rank_refresh` untouched by default, records +`"rank_refresh": "candidate_default"` and +`"rank_refresh_override_applied": false`, and therefore measures each candidate's +real compiled/configured policy. Use `--rank-refresh at_publish`, +`defer_exact_delta_reindexes`, or `defer_all_incremental_reindexes` only for an +explicit policy experiment. The harness reads the versioned spelling map and +translates these values only when running a retained candidate that predates the +canonical names. + +Each semantic pair case also supplies a content-addressed replacement source. A real +one-file mutation removes one judged positive and adds another, retaining pre/post +source hashes and changed paths. The harness records initial, incremental, and fresh +index measurements; pre/post confusion witnesses; freshness warnings; exact publish +kind; bounded pair equality; and whole canonical-graph equality. This prevents a +no-op reindex or a stale expected edge from being reported as successful changed-file +quality. + +For a realistic background, add this top-level compact-spec object: + +```json +{ + "quality_background": { + "repo": "/absolute/path/to/source-checkout", + "revision": "0123456789abcdef0123456789abcdef01234567", + "tree": "89abcdef0123456789abcdef0123456789abcdef" + } +} +``` + +This is supported by `similarity` and `semantic_edges` quality cases. The harness +streams tracked files from that exact commit through `git archive`, excluding the +source checkout's dirty and untracked state, then overlays the versioned canaries in +the isolated per-cell repository. It removes its transient tar archive after safe +extraction. The cell identity binds the resolved source path, commit, and tree; +result acceptance rejects a missing or mismatched retained commit/tree identity. +Neither the source checkout nor its worktree registry is modified. + +Capability ablations should use the named `--config-profile` values so an important +cost center cannot be silently omitted. Repeated `--config KEY=VALUE` arguments +remain available and take priority over the selected profile. The benchmark default, +`automatic_dependency_source_indexing_disabled`, explicitly pins the current product +capability values and sets `auto_index_deps=false`. Use +`automatic_dependency_source_indexing_enabled` for the same capability set with +`auto_index_deps=true`. `candidate_native_configuration` applies no overrides and is +reserved for older candidates that do not implement the current configuration keys; +its unspecified effective values cannot participate in capability-parity joins. The +PageRank/LinkRank ablation is: + +```text +--config-profile rank_disabled +``` + +`benchmarks/autotune.py` is a safe frontend for the corresponding PageRank parameter +sweep. It requires exact build metadata, generates a content-addressed rank-quality +experiment, interleaves candidate-default and ablation repetitions, and stores the +plan, results, logs, and report under a durable ignored result root. It does not +change the normal user configuration or cache. Use `--plan-only` to validate and +inspect the expanded cells before spending CPU time. + +The independent `--mcp-surface-parity` mode records classic, streamlined before +reveal, and the same streamlined process after reveal. It compares names plus the +full `tools/list` client contract (description, input/output schemas, and MCP +annotations), reports user outcomes before tool counts, checks bounded pre-reveal +handler recognition, and requires server processes and reader threads to be reaped. +These probes establish discovery and dispatch parity; functional quality claims +must still come from the capability fixtures and repository workloads below. + +The minimal-indexing ablation disables every optional graph cost center and retains +`auto_index_deps=false`: + +```text +--config-profile minimal_indexing +``` + +`optional_graph_disabled` is accepted only when loading retained plans and resolves +to this same manifest. Do not use the retired spelling in new plans. + +The immediate semantic/similarity freshness profile is: + +```text +--config-profile incremental_derived_results_refresh_at_publish +``` + +It changes only `incremental_derived_results_refresh=at_publish`. The default +`defer_all_incremental_reindexes` policy may publish an exact or containment delta +after marking global `SIMILAR_TO`/`SEMANTICALLY_RELATED` views stale; graph queries +must then retain an explicit freshness warning until an at-publish or full rebuild. +Reports score this warning as policy conformance, not an unexplained execution +failure, but they keep immediate semantic task quality false. The at-publish profile +must produce the post-mutation judged pair set and edge scores identically to a fresh +rebuild without a stale warning. Compare both profiles when selecting a +latency/freshness Pareto point. + +Cross-version candidate-default cells do not assume that older binaries share the +latest binary's derived-refresh default. With no explicit +`incremental_derived_results_refresh` override, the retained policy is +`candidate_default` and the harness classifies observed behavior as immediate pair +freshness, deferred with a structured warning, or unreported stale output. Explicit +at-publish/deferred profiles continue to validate against the requested policy. This +keeps an older immediate-refresh default from being judged against a newer deferred +default. + +Large mutation reports keep Core graph and Full graph freshness separate. A +`PASS: DECLARED STALE VIEWS` decision requires structured `stale_with_warning` +metadata and a second canonical comparison that excludes only the declared +`SEMANTICALLY_RELATED` rows. Every remaining node, edge, property, and file hash +must still equal the matching fresh rebuild. An undeclared difference—or any +non-semantic difference—remains a core correctness failure. Full graph freshness +stays zero until the unfiltered graphs match, so the latency/freshness tradeoff is +visible rather than relabeled as full equality. + +The lowest-cost indexing baseline also disables installed-package indexing and is: + +```text +--config-profile minimal_indexing +``` + +`minimal_indexing` expands to `auto_index_deps=false`, `rank_enabled=false`, +`similarity_enabled=false`, `semantic_edges_enabled=false`, +`githistory_enabled=false`, and `httplinks_enabled=false`. Reports retain both the +profile name and the fully expanded requested/effective override maps for +auditability. Each benchmark case removes inherited `CBM_*` product variables, +uses an isolated cache, and records that worker selection follows the candidate's +native default with `CBM_WORKERS` unset unless the matrix explicitly declares +`product_environment`. Explicit values are recorded in the cell identity and report +environment policy before being applied to the candidate process. Candidate-native +profiles record effective configuration as unknown instead of inferring defaults +that an older binary did not report. + +Only apply gates a candidate revision actually supports. Record unsupported +combinations as compatibility findings rather than silently treating them as the +same configuration. + +## Run and resume + +```sh +uv run python benchmarks/run_experiments.py \ + --plan .worktrees/benchmark-campaign/plan.json \ + --experiment-root .worktrees/benchmark-campaign/results +``` + +The retained `--campaign-root` spelling remains accepted. Archived plans that name +the former single-run script path resolve it to `benchmarks/run_benchmark.py` at +execution time without rewriting the archived plan or changing its cell identity. + +Rerunning the same command resumes validated cells. The runner executes cells +sequentially by default so concurrent indexing does not distort latency or peak RSS. +Each cell retains immutable timestamped attempts with `command.json`, `stdout.log`, +`stderr.log`, `result.json`, and `attempt.json`. `complete.json` is written with an +atomic replace only after validation. Per-cell exclusive locks reject a live or +recent competing run; stale lock recovery is recorded instead of hidden. +Each benchmark command runs in an isolated process group. Timeout or user interrupt +signals the whole group, waits up to 30 seconds for the harness to remove its cache +and detached worktrees, then force-stops any remaining descendants. The immutable +attempt record is written before an interrupt is re-raised. + +`command.json` records a pre-run resource snapshot and `attempt.json` records a +post-run snapshot: UTC time, hostname, CPU count, physical memory when the platform +exposes it, 1/5/15-minute load average when available, and experiment-root +filesystem total, used, and free bytes. The experiment-level environment snapshot +retains the same host data. These observations diagnose load or disk drift; they +are not substitutes for per-process peak RSS recorded by the benchmark itself. + +Every invocation also writes: + +- an immutable copy of the plan keyed by its SHA-256; +- a timestamped environment snapshot and manifest; +- counts for planned, complete, missing, corrupt, duplicate-attempt, and unplanned + run directories; +- `reports/summary.md`, regenerated from validated completion records only. + +The report lists exact bytes from each tool's default response encoding and a clearly +labeled deterministic `ceil(UTF-8 bytes / 4)` token estimate. Each quality oracle makes +a second request with `format=json`; its latency and canonical JSON size are recorded +separately as `quality_probe_elapsed_ms` and `quality_response_bytes`, so parsing the +oracle cannot silently replace or inflate the default user-facing measurement. Pareto +membership is restricted to candidates that pass every applicable quality/correctness +gate and have query latency, response tokens, incremental latency, and peak RSS +measurements. It maximizes quality while minimizing those cost axes. Exact bytes remain +visible so the token estimate is never presented as tokenizer ground truth. +Peak RSS and internal indexing time are extracted in one streaming pass from the +worker logfile named by the index response. The harness sets `CBM_PROFILE=1` for +every candidate because successful supervisors otherwise delete that logfile; this +also makes the profiling configuration consistent and visible across revisions. +Only the exact `mem.phase`, +`pipeline.done`, and `incremental.done` marker lines (at most 512) are retained in +the result, keeping memory bounded while preserving the evidence after transient +worker logs are cleaned. + +Use `--audit-only` to scan and regenerate the report without running missing cells. +The audit re-inventories every completed attempt's artifact directory and rejects +changed, missing, or unlisted worker logs rather than trusting `attempt.json` alone. +Use `--minimum-free-gb` and `--stale-lock-hours` only when the recorded defaults are +inappropriate for the host. + +`--quick` and `--full` build their candidate set from `DEFAULT_CANDIDATE_REFS`, +which pins one baseline (`upstream/main`) and two dated tags. The baseline falls +back from `upstream/main` to `origin/main` to `main` automatically if the pinned +ref does not resolve (for example, after the `upstream` remote is removed +post-merge). Use repeatable `--candidate-ref LABEL=REF` (for example +`--candidate-ref upstream-main=origin/main`) to override any default candidate's +ref explicitly; an explicit override is fail-closed like the rest of the runner — +an unresolvable override ref raises rather than silently substituting a different +comparison point. + +Automatic presets use MCP transport by default. That is appropriate when the +benchmark owns an isolated account/runtime or all candidates are compatible with +the active account-wide CBM daemon. Neither MCP nor CLI is a valid cross-build setup +when another CBM build is serving that account: current one-shot `config`, index, +and query CLI commands enforce the same exact-build cohort and correctly reject a +different candidate before the benchmark can use its isolated cache. + +Select CLI transport explicitly in that situation: + +```sh +uv run python benchmarks/run_experiments.py --full --transport cli \ + --candidate-ref upstream-main=main \ + --experiment-root /durable/path/full-head-vs-main +``` + +CLI transport runs the same candidate binaries, profiles, scenarios, repetitions, +quality gates, and isolated caches without MCP framing, but it still participates in +account-wide exact-build coordination. Run a cross-build CLI matrix in a dedicated +OS account/runtime or after quiescing that account's daemon, and restore normal +dogfooding afterward. Changing `CBM_CACHE_DIR`, the Git worktree, or the experiment +root is not daemon isolation. The runner never silently falls back between CLI and +MCP. Use MCP to measure protocol/daemon overhead and CLI to isolate framing cost. +The benchmark runs directly on the host and does not require Docker. + +Candidate labels are stable comparison roles. In the example, +`upstream-main` still appears as the role label even though it resolves the local +`main` ref. Reports and claims must therefore cite the resolved ref and exact commit +recorded in the expanded plan, not infer the source ref from the role label. + +## Cross-experiment composition + +Use `benchmarks/summarize_results.py --composition-spec SPEC --out REPORT` +to combine incremental correctness and capability-quality evidence into one +configuration row. A composition input may name an exact matrix spec or the immutable +expanded plan already archived in its durable experiment root. The generator validates +the selected plan, requires every selected cell to have a hash-validated completion, +and consumes the derived report inputs without altering immutable raw results. Using +an archived plan permits historical report regeneration without requiring the old +candidate executable path to still exist. + +The generated Markdown records the composition-spec SHA-256. A sibling +`REPORT.manifest.json` records every materialized input path and SHA-256, making the +uncommitted report reproducible and auditable without committing experiment logs or +results. + +Reports show observation counts, medians, and min–max ranges for incremental, query, +and full-index latency. The ranges are descriptive, not confidence intervals: the +default sequential grouped order avoids concurrent contention but is not a paired or +randomized design suitable for an effect-size interval. diff --git a/docs/BENCHMARK_TERMINOLOGY.md b/docs/BENCHMARK_TERMINOLOGY.md new file mode 100644 index 000000000..5a1115773 --- /dev/null +++ b/docs/BENCHMARK_TERMINOLOGY.md @@ -0,0 +1,154 @@ +# Benchmark terminology + + + +- Terminology version: `1.1.0` +- Canonical registry: `benchmarks/terminology.json` +- Canonical-content SHA-256: `04b73a6474ea9f257448ff09f136b0ed675452afee5c75bfd7d21a7b9bdc6bee` + +Every definition below is normative. Parent relations describe containment, not execution order; overlapping elapsed spans are work-time evidence and must not be summed into lifecycle wall time. + +## Algorithm Concept + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `dependency_artifact_reuse`
Dependency Artifact Reuse | Dependency artifact reuse loads a previously computed dependency graph only when package identity, source hash, parser version, config and schema version, and capability set all match. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `graph_publication`
Graph Publication | Graph publication is the transaction that makes computed node, edge, property, index, and generation changes visible in the persistent store. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `lsh_index`
LSH index | A locality-sensitive-hashing index groups semantic vectors into candidate buckets so the semantic pass need not compare every pair. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `node_degree`
Node Degree | Node degree is the configured weighted, unweighted, or calls-only connection count for one graph node. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `semantic_vector`
Semantic Vector | A semantic vector is the recorded numeric representation of one code entity used by the semantic-similarity algorithm. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Benchmark Concept + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `benchmark_cell`
Benchmark Cell | A benchmark cell is the set of repetitions that share one declared implementation, workload, effective capability manifest, scope manifest, cache manifest, and correctness contract. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `benchmark_result`
Benchmark Result | A benchmark result is one recorded correctness, freshness, retrieval, ranking, semantic-quality, skip, error, or product-failure outcome for a benchmark run. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `benchmark_run`
Benchmark Run | A benchmark run is one execution of the measured product operation with one resolved implementation, capability, scope, and cache manifest. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `cache_manifest`
Cache Manifest | A cache manifest records the state and reset procedure for every named cache layer; the report does not use an unqualified cold or warm label. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `critical_path`
Critical Path | A user lifecycle's critical path is the longest-duration path through its explicit dependency relations. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `dependency_relation`
Dependency Relation | A dependency relation records that one step occurrence must reach a named event before another occurrence can proceed. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `generated_source`
Generated Source | Generated source is machine-produced or vendored source selected by an explicit recorded policy. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `implementation_identity`
Implementation Identity | An implementation identity is the source revision, binary hash, and build manifest of the compared executable. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `invalid_benchmark_record`
Invalid Benchmark Record | An invalid benchmark record is measurement evidence rejected because its instrumentation, schema, terminology, or oracle requirements failed. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `observer_effect`
Observer Effect | Observer effect is the latency, CPU, or memory difference caused by instrumentation, measured against profiler-off cells using the same executable and workload. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `overlap`
Overlap | Two step occurrences overlap when their monotonic execution intervals intersect. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `parent_relation`
Parent Relation | A parent relation records structural nesting between two step occurrences and does not by itself impose execution order. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `product_failure`
Product Failure | A product failure occurs when the indexed or query operation violates its recorded product contract or returns a failing product status. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `production_build`
Production Build | A production build is an executable built with the shipped optimization, sanitizer, and feature flags recorded in its build manifest. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `repetition`
Repetition | A repetition is one independently started benchmark run in a cell; repetitions share the cell configuration but not mutable process state unless the cache manifest says otherwise. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `retained_artifact`
Retained Artifact | A retained artifact is one benchmark input or output identified by path, content hash, schema version, terminology version, and cleanup state. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `scope_manifest`
Scope Manifest | A scope manifest identifies every included repository, dependency package, file and byte count, language, generated-source policy, and exclusion. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `step`
Step | A step is a registry-defined kind of work performed during a benchmark run. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `step_occurrence`
Step Occurrence | A step occurrence is one execution of a step; every repeated or concurrent occurrence has its own occurrence ID. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `timer_boundary`
Timer Boundary | A timer boundary is a registry-defined event, owned by the harness or a named process, that starts or ends a duration. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `user_lifecycle`
User Lifecycle | A user lifecycle is one user-visible operation measured between two harness-owned monotonic boundary events. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Capability State + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `capability`
Capability | A capability is one separately observable product behavior. | existing; capability record or categorical state; enabled, disabled, unsupported, or an exact enumerated/numeric value; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: per-call override > environment > persistent config > preset > compiled default; effect: determines parity eligibility and the work/correctness contract | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `disabled_capability`
Disabled Capability | A disabled capability is implemented by the measured executable but inactive for the benchmark run. | existing; capability record or categorical state; enabled, disabled, unsupported, or an exact enumerated/numeric value; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: per-call override > environment > persistent config > preset > compiled default; effect: determines parity eligibility and the work/correctness contract | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `effective_capability_value`
Effective Capability Value | An effective capability value is selected after applying default, preset, persistent-config, environment, and per-call precedence; its winning source is recorded. | existing; capability record or categorical state; enabled, disabled, unsupported, or an exact enumerated/numeric value; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: per-call override > environment > persistent config > preset > compiled default; effect: determines parity eligibility and the work/correctness contract | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `enabled_capability`
Enabled Capability | An enabled capability is implemented by the measured executable and active for the benchmark run. | existing; capability record or categorical state; enabled, disabled, unsupported, or an exact enumerated/numeric value; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: per-call override > environment > persistent config > preset > compiled default; effect: determines parity eligibility and the work/correctness contract | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `unsupported_capability`
Unsupported Capability | An unsupported capability is unavailable in the measured executable; missing capability metadata instead makes the benchmark record invalid. | existing; capability record or categorical state; enabled, disabled, unsupported, or an exact enumerated/numeric value; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: per-call override > environment > persistent config > preset > compiled default; effect: determines parity eligibility and the work/correctness contract | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Comparison Kind + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `capability_delta_comparison`
Capability Delta Comparison | A capability-delta comparison compares cells with an explicitly named capability difference and reports the added or removed work, quality, coverage, and resource cost without a cross-implementation speed ratio. | existing; comparison record; one comparison whose join and formula identifiers are recorded; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: derive only from source run, result, and step IDs retained in the record; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: reject the comparison when a required join field is unknown; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `parity_comparison`
Parity Comparison | A parity comparison compares two benchmark cells whose effective capabilities, input and scope policies, per-layer cache states, timer boundaries, freshness endpoints, and correctness contracts are identical. | existing; comparison record; one comparison whose join and formula identifiers are recorded; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: derive only from source run, result, and step IDs retained in the record; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: reject the comparison when a required join field is unknown; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `shared_work_projection`
Shared Work Projection | A shared-work projection compares the explicitly named intersection of work supported by two implementations and is not whole-product parity. | existing; comparison record; one comparison whose join and formula identifiers are recorded; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: derive only from source run, result, and step IDs retained in the record; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: reject the comparison when a required join field is unknown; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Evidence Status + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `existing_behavior`
Existing Behavior | Existing behavior is behavior present at the cited source revision and verified at the cited code or experiment anchor. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `proposed_behavior`
Proposed Behavior | Proposed behavior is design work described by this plan but not implemented at the cited source revision. | existing; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Formula Id + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `left_elapsed_divided_by_right_elapsed_v1`
Left/Right Elapsed Ratio Formula v1 | The `left_elapsed_divided_by_right_elapsed_v1` formula ID divides the left cell's median elapsed milliseconds by the right cell's median elapsed milliseconds for the same step ID; values above 1 mean the left cell took longer. | existing; stable string identifier; exactly `left_elapsed_divided_by_right_elapsed_v1`; dimensionless; scope: ratio of wall-time medians | boundaries: inherits the source occurrences used by both median operands; aggregation: divide the left cell's median_elapsed_ms_v1 result by the right cell's median_elapsed_ms_v1 result for the same step ID; concurrency: does not sum component durations; both operands preserve their recorded occurrence boundaries | missing/unsupported: emit null when the right median is zero and emit no ratio unless the pair passed the parity join; configuration: not_applicable; effect: valid only for a parity_manifest_and_contract_v1 join | `benchmarks/schema/comparisons-v1.schema.json`, `benchmarks/fact_comparisons.py` | +| `median_elapsed_ms_v1`
Median Elapsed Milliseconds Formula v1 | The `median_elapsed_ms_v1` formula ID sorts the selected elapsed_ms values and returns the middle value for an odd count or the arithmetic mean of the two middle values for an even count. | existing; stable string identifier; exactly `median_elapsed_ms_v1`; milliseconds; scope: wall time for each named occurrence | boundaries: uses each source occurrence's registered monotonic start and end boundaries; aggregation: apply to all recorded elapsed_ms values for one step ID in one exact cell group; concurrency: does not sum overlapping occurrences; it takes the median of the selected occurrence durations | missing/unsupported: omit the aggregate when no numeric elapsed_ms occurrence is recorded; configuration: not_applicable; effect: none beyond the enclosing cell manifest | `benchmarks/schema/comparisons-v1.schema.json`, `benchmarks/fact_comparisons.py` | + +## Freshness And Correctness + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `all_fresh_endpoint`
All Fresh Endpoint | An all-fresh endpoint occurs when the core graph and every enabled derived view in the effective capability manifest are fresh. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `clean_rebuild_graph_oracle`
Clean Rebuild Graph Oracle | A clean-rebuild graph oracle is the canonically normalized graph produced from an empty store using the same source snapshot, effective capability manifest, and scope manifest as the compared run. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `core_answer`
Core Answer | A core answer is a task answer computed from a core graph whose source generation matches the latest successful source publication. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `core_graph`
Core Graph | A core graph contains the source-derived nodes, edges, properties, and file hashes that remain after removing only the optional derived views explicitly listed in the benchmark result. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `correctness_contract`
Correctness Contract | A correctness contract specifies the graph rows, properties, hashes, freshness states, task outcomes, and allowed exclusions that a benchmark result must satisfy. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `deferred_refresh`
Deferred Refresh | A deferred refresh leaves the named derived view stale at the measured endpoint and reports its stale state and generation. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `derived_view`
Derived View | A derived view is named data recomputed from the source graph. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `eager_refresh`
Eager Refresh | An eager refresh computes and publishes the named derived view before the measured endpoint returns. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `fresh_view`
Fresh View | A fresh view is a derived view whose view generation equals the latest successfully published source generation at the measured endpoint. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `graph_equality`
Graph Equality | Graph equality means equality under the recorded canonicalization version and does not require byte-identical SQLite files. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `requested_fresh_endpoint`
Requested Fresh Endpoint | A requested-fresh endpoint occurs when the core graph and every enabled derived view required by the named task are fresh. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `source_generation`
Source Generation | A source generation is the monotonic identifier assigned to one successful publication of source-derived graph data. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `stale_view`
Stale View | A stale view is a derived view whose view generation precedes the latest successfully published source generation. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `view_generation`
View Generation | A view generation is the source generation used to compute one named derived view. | existing; freshness, generation, oracle, or endpoint record; values defined by the recorded correctness contract; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: determines whether a lifecycle reached core, requested-fresh, or all-fresh completion | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Join Id + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `capability_delta_manifest_v1`
Capability Delta Manifest Join v1 | The `capability_delta_manifest_v1` join ID selects two cells only when mode, scope, cache state, host, benchmark contract, and correctness contract are canonically equal, both capability manifests are complete, and at least one effective capability differs; it never authorizes a cross-implementation speed ratio. | existing; stable string identifier; exactly `capability_delta_manifest_v1`; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: not_applicable; concurrency: does not imply serial execution; compared durations retain their recorded occurrence structure | missing/unsupported: classify the pair as not eligible when required equal fields differ or either capability manifest is incomplete; configuration: not_applicable; effect: requires one or more explicitly recorded capability differences | `benchmarks/schema/comparisons-v1.schema.json`, `benchmarks/fact_comparisons.py` | +| `parity_manifest_and_contract_v1`
Parity Manifest and Contract Join v1 | The `parity_manifest_and_contract_v1` join ID selects two cells only when mode, effective capabilities, scope, cache state, host, benchmark contract, and correctness contract are canonically equal, both capability manifests are complete, and no required manifest value is unknown. | existing; stable string identifier; exactly `parity_manifest_and_contract_v1`; not_applicable; scope: not_applicable | boundaries: not_applicable; aggregation: not_applicable; concurrency: does not imply serial execution; compared durations retain their recorded occurrence structure | missing/unsupported: classify the pair as not eligible and emit no ratio; configuration: not_applicable; effect: requires identical effective capability, scope, cache, host, benchmark-contract, and correctness-contract records | `benchmarks/schema/comparisons-v1.schema.json`, `benchmarks/fact_comparisons.py` | + +## Measurement + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `confidence_interval`
Confidence Interval | A confidence interval is the interval produced by the recorded statistical method, confidence level, and repetition set for one estimator. | existing; number or bounded interval; values permitted by the measured quantity; the unit of the measured quantity; scope: not_applicable | boundaries: not_applicable; aggregation: apply only the versioned estimator to one declared repetition set; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `cpu_time`
Cpu Time | CPU time is processor execution time measured for a named thread, process, or child-process set. | existing; nonnegative number; 0 or greater; milliseconds in fact tables; source clocks use nanoseconds; scope: the named monotonic clock and thread, process, process-tree, or harness scope | boundaries: the registered start event through the registered end event; aggregation: aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `elapsed_time`
Elapsed Time | A step occurrence's elapsed time is its monotonic end timestamp minus its monotonic start timestamp. | existing; nonnegative number; 0 or greater; milliseconds in fact tables; source clocks use nanoseconds; scope: the named monotonic clock and thread, process, process-tree, or harness scope | boundaries: the registered start event through the registered end event; aggregation: aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `lifecycle_wall_time`
Lifecycle Wall Time | A user lifecycle's wall time is its harness-owned end boundary minus its harness-owned start boundary. | existing; nonnegative number; 0 or greater; milliseconds in fact tables; source clocks use nanoseconds; scope: the named monotonic clock and thread, process, process-tree, or harness scope | boundaries: the registered start event through the registered end event; aggregation: aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `median`
Median | A median is the versioned 50th-percentile estimator over the recorded repetitions in one benchmark cell. | existing; number or bounded interval; values permitted by the measured quantity; the unit of the measured quantity; scope: not_applicable | boundaries: not_applicable; aggregation: apply only the versioned estimator to one declared repetition set; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `p95`
p95 | A p95 value is the versioned 95th-percentile estimator over the recorded repetitions in one benchmark cell. | existing; number or bounded interval; values permitted by the measured quantity; the unit of the measured quantity; scope: not_applicable | boundaries: not_applicable; aggregation: apply only the versioned estimator to one declared repetition set; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `parallelism`
Parallelism | Parallelism is the number of step occurrences actively executing during a declared monotonic interval. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `peak_rss`
Peak RSS | Peak resident set size is the largest resident-memory sample observed for the named process set within declared boundaries. | existing; number; peak_rss is nonnegative; rss_delta may be negative; MiB; scope: the named process or process-tree sampling interval | boundaries: the registered lifecycle or step sampling boundaries; aggregation: peak uses max; delta uses end minus start; never add peaks; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `queue_wait`
Queue Wait | A step occurrence's queue wait is its worker-start timestamp minus its enqueue timestamp. | existing; nonnegative number; 0 or greater; milliseconds in fact tables; source clocks use nanoseconds; scope: the named monotonic clock and thread, process, process-tree, or harness scope | boundaries: the registered start event through the registered end event; aggregation: aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `ratio`
Ratio | A ratio is a named numerator divided by a named nonzero denominator under one declared comparison contract. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `rss_delta`
RSS delta | Resident-set delta is end-boundary RSS minus start-boundary RSS for the named process set. | existing; number; peak_rss is nonnegative; rss_delta may be negative; MiB; scope: the named process or process-tree sampling interval | boundaries: the registered lifecycle or step sampling boundaries; aggregation: peak uses max; delta uses end minus start; never add peaks; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `speedup`
Speedup | A speedup is baseline duration divided by candidate duration under one declared parity or shared-work-projection contract; values above 1 mean the candidate completed faster. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `work_time`
Work Time | Work time is the sum of selected step-occurrence elapsed times and may exceed lifecycle wall time when occurrences overlap. | existing; nonnegative number; 0 or greater; milliseconds in fact tables; source clocks use nanoseconds; scope: the named monotonic clock and thread, process, process-tree, or harness scope | boundaries: the registered start event through the registered end event; aggregation: aggregate only occurrence sets selected by an emitted formula; lifecycle wall time is always outer end minus outer start; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `worker_utilization`
Worker Utilization | Worker utilization is active worker time divided by available worker time for a named worker pool and interval. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Quality Metric + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `hit_at_k`
Hit@k | Hit@k is the fraction of applicable retrieval tasks whose named correct entity appears within the first k returned entities; higher is better. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `mrr`
MRR | Mean reciprocal rank is the mean of 1/rank for the first correct returned entity in each applicable retrieval task; higher is better and 1 means every correct entity ranked first. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `ndcg_at_k`
nDCG@k | Normalized discounted cumulative gain at k scores the order of judged returned entities within the first k positions against the ideal order; higher is better and 1 is ideal. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `semantic_pair_f1`
Semantic Pair F1 | Semantic Pair F1 is the harmonic mean of precision and recall over the explicitly judged SEMANTICALLY_RELATED code-entity pairs; higher is better and 1 means none are missing or spurious. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `task_success`
Task Success | Task success is the fraction of applicable named tasks that return their required entity or evidence under the task's recorded acceptance rule. | existing; nonnegative number; 0 or greater; ratio denominators must be nonzero; dimensionless; scope: not_applicable | boundaries: not_applicable; aggregation: use the formula and repetition estimator recorded with the result; concurrency: does not imply serial execution; use recorded occurrence intervals and dependency relations | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | + +## Step Id + +| ID | Normative definition | Status; type; unit | Boundaries; aggregation; concurrency | Missing/configuration/effect | Sources | +|---|---|---|---|---|---| +| `change_classification`
Change Classification | The `change_classification` step ID identifies one occurrence of change classification work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `change_classification`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `dependency_discovery`
Dependency Discovery | The `dependency_discovery` step ID identifies one occurrence of dependency discovery work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `dependency_discovery`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `dependency_package_index`
Dependency Package Index | The `dependency_package_index` step ID identifies one occurrence of dependency package index work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `dependency_package_index`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `exact_delta`
Exact Delta | The `exact_delta` step ID identifies one occurrence of exact delta work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `exact_delta`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `first_all_fresh_query`
First All Fresh Query | The `first_all_fresh_query` step ID identifies one occurrence of first all fresh query work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `first_all_fresh_query`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `first_core_query`
First Core Query | The `first_core_query` step ID identifies one occurrence of first core query work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `first_core_query`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `graph_publish_delete`
Graph Publish Delete | The `graph_publish_delete` step ID identifies one occurrence of graph publish delete work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `graph_publish_delete`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `graph_publish_indexes`
Graph Publish Indexes | The `graph_publish_indexes` step ID identifies one occurrence of graph publish indexes work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `graph_publish_indexes`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `graph_publish_upsert`
Graph Publish Upsert | The `graph_publish_upsert` step ID identifies one occurrence of graph publish upsert work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `graph_publish_upsert`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `linkrank`
LinkRank | LinkRank is the configured edge score derived from stationary flow between graph nodes. The same `linkrank` registry ID identifies each measured occurrence of that work; repeated or concurrent occurrences have distinct occurrence IDs. | proposed; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `pagerank`
PageRank | PageRank is the configured graph-centrality score computed from incoming weighted graph links. The same `pagerank` registry ID identifies each measured occurrence of that work; repeated or concurrent occurrences have distinct occurrence IDs. | proposed; object or categorical record; values defined by the referenced benchmark schema; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: must not be aggregated unless a referenced formula defines the operation; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: record an explicit unknown fact; do not infer the value from a label or preset; configuration: not_applicable; effect: none beyond the term's normative definition | `benchmarks/schema/facts-v2.schema.json`, `benchmarks/run_benchmark.py` | +| `parse_extract`
Parse Extract | The `parse_extract` step ID identifies one occurrence of parse extract work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `parse_extract`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `project_discovery`
Project Discovery | The `project_discovery` step ID identifies one occurrence of project discovery work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `project_discovery`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `semantic_lsh`
Semantic Lsh | The `semantic_lsh` step ID identifies one occurrence of semantic lsh work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `semantic_lsh`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `semantic_pairs`
Semantic Pairs | The `semantic_pairs` step ID identifies one occurrence of semantic pairs work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `semantic_pairs`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `semantic_vectors`
Semantic Vectors | The `semantic_vectors` step ID identifies one occurrence of semantic vectors work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `semantic_vectors`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | +| `startup`
Startup | The `startup` step ID identifies one occurrence of startup work; each repeated or concurrent occurrence has a distinct occurrence ID. | proposed; stable string identifier; exactly `startup`; not_applicable; scope: the thread, worker, process, or harness recorded on each occurrence | boundaries: the occurrence's registered start and end events; aggregation: aggregate only distinct occurrence IDs selected by an emitted formula; concurrency: overlapping occurrences remain separate and are not summed into lifecycle wall time | missing/unsupported: omit no required occurrence; mark the benchmark record invalid if detail collection overflows; configuration: profiling level and benchmark specification select whether this step is emitted; effect: the enclosing run records the capability and freshness contract; the step ID alone implies neither | `src/foundation/profile.h`, `benchmarks/run_benchmark.py` | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e8dc608c3..ba4e54e78 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -75,17 +75,66 @@ Inspect or change values with the CLI: ```bash codebase-memory-mcp config list codebase-memory-mcp config get auto_index +codebase-memory-mcp config describe pagerank_damping codebase-memory-mcp config set auto_index true codebase-memory-mcp config set auto_index_limit 50000 codebase-memory-mcp config reset auto_index ``` -Current keys: +Important keys (`config list` shows common effective values; use `config get ` +for any registry key): | Key | Default | Meaning | |---|---|---| -| `auto_index` | `false` | Automatically index new projects when an MCP session starts. | +| `build_fingerprint_mode` | `cached_exact` | Exact-build verification cost policy: reuse a checksummed SHA-256 only for an unchanged kernel-bound native file (`cached_exact`), or hash the complete process image at every startup (`always_rehash`). Installer/update and Windows launcher payload verification always rehash. | +| `auto_index` | `true` | Automatically index new projects at MCP startup or first graph use. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | +| `auto_watch` | `true` | Register indexed projects for automatic background Git-change refresh. | +| `tool_mode` | `streamlined` | MCP discovery surface: `streamlined` or `classic`. | +| `context_injection` | `true` | Include bounded project/index status, recovery guidance, freshness, coverage, schema, and graph stats automatically in the first tool response; later responses include only `session_project`. | +| `rank_enabled` | `true` | Compute PageRank, LinkRank, and degree views used by relevance ranking. | +| `auto_index_deps` | `false` | Automatically index installed dependency source for cross-package search and tracing. | +| `auto_dep_limit` | `20` | Import-ranked automatic dependency package cap; `0` is unlimited. | +| `dep_max_files` | `1000` | Maximum source files per automatically indexed dependency package; larger packages are skipped atomically, and `0` is unlimited. | +| `similarity_enabled` | `true` | Create MinHash similarity edges in applicable index modes. | +| `semantic_edges_enabled` | `true` | Create semantic-related edges in applicable index modes. | +| `githistory_enabled` | `true` | Create Git co-change coupling edges. | +| `httplinks_enabled` | `true` | Link HTTP clients to discovered routes. | +| `default_response_format` | `toon` | Tool-response encoding when a call omits `format`: `toon` (compact tables) or `json` (full objects). A per-call `format` argument always wins. | + +Normal streamlined exploration uses `search_graph`, `trace_path`, `get_code`, and +`query_graph` as needed; automatic indexing and first-response context follow their +settings. Classic structural discovery uses `search_graph`, then `trace_path`, then +`get_code_snippet`; use `query_graph` or `get_architecture` for broader structure. +Classic mode advertises advanced tools directly. + +### Named presets + +Presets atomically apply exact capability sets, so a prior manual setting cannot +silently leak into a comparison: + +```bash +codebase-memory-mcp config preset list +codebase-memory-mcp config preset apply streamlined-automatic-dependency-source-indexing-disabled +codebase-memory-mcp config preset apply streamlined-automatic-dependency-source-indexing-enabled +codebase-memory-mcp config preset apply classic-automatic-dependency-source-indexing-disabled +codebase-memory-mcp config preset apply classic-automatic-dependency-source-indexing-enabled +``` + +The four product presets pair the `streamlined` or `classic` tool surface with an +explicit automatic dependency-source indexing state. All four enable the same rank, +similarity, semantic-edge, Git-history, and HTTP-link capabilities. The disabled +variants bound default indexing latency, CPU, memory, and stored graph size; the +enabled variants add installed dependency-source coverage up to `auto_dep_limit`; +`dep_max_files` skips oversized packages rather than publishing partial API coverage. +`index_dependencies` remains available for explicit packages. Disabling automation +stops future automatic dependency indexing but does not delete dependency projects +already indexed. `rank-disabled` and `minimal-indexing` are benchmark ablations, and +the CLI labels them accordingly. The `minimal-indexing` preset disables optional graph +passes and dependency-source automation; the post-edit reindex strategy is unchanged. +Environment variables remain higher priority than stored preset values, and preset +application returns nonzero when an active override prevents the requested effective +configuration. ## 3. UI Settings @@ -127,7 +176,7 @@ Environment used by daemon-owned components—such as diagnostics, daemon loggin ## 5. Agent and Editor Integration Files -The `install` command can also write MCP entries and instruction blocks into agent/editor config files such as Claude Code, Codex, Gemini, VS Code, Cursor, Zed, and others. +The `install` command can also write MCP entries and owned instruction blocks into detected agent/editor config files. Supported targets include Claude Code, Claude Desktop, Codex, Gemini, Qwen Code, ForgeCode, Antigravity, OpenCode, Zed, VS Code and its profiles, Cursor, Windsurf, KiloCode, OpenClaw, Kiro, and Junie; Aider receives CLI-form instructions because it does not expose MCP. Those target paths vary by tool and platform, so the easiest way to inspect the exact files for your machine is: @@ -136,3 +185,4 @@ codebase-memory-mcp install --dry-run ``` That prints the specific config files the installer would modify without writing anything. +`uninstall --dry-run` is also read-only, including when combined with `-y`; it reports the index action that would occur without prompting or deleting indexes. diff --git a/docs/EVALUATION_PLAN.md b/docs/EVALUATION_PLAN.md index 5f8bb532c..e5c456a38 100644 --- a/docs/EVALUATION_PLAN.md +++ b/docs/EVALUATION_PLAN.md @@ -846,7 +846,7 @@ Deep-Dive section. > the question — that's exactly the gap symmetric authoring is designed to expose. > > **Pinning.** During authoring, the repo's resolved commit SHA is recorded and baked into -> `clone-bench-repos.sh`, so the run indexes the *same* HEAD the questions were written against. +> `scripts/clone-bench-repos.sh`, so the run indexes the *same* HEAD the questions were written against. > > §14 contains two fully-worked exemplars now; the remaining 157 are generated against their cloned > repos (§15) following this authoring split. @@ -1061,7 +1061,7 @@ The plan proposes "3–5 known near-duplicate / copy-pasted function pairs found | C cross-repo pair (redis/hiredis, RESP protocol) produces 0 CROSS edges | High | Medium | Already flagged — treat as documented gap; consider using a WASM/Wasm-C host if a genuine C HTTP service pair can be found | | 159-language sweep is not completable in one session without checkpointing | High | Medium | Add explicit checkpoint/resume logic to the script; describe failure-recovery in §13 | | ~30 flagged ⚠️ repos unavailable, too small, or wrong language on run day | Medium | Medium | Validate all ⚠️ rows before authoring questions; fallback fixture corpus per §8.1 | -| Shallow clone at run time produces a different HEAD than during question authoring | Medium | Medium | Pin repos by commit SHA during authoring; bake SHA into `clone-bench-repos.sh` | +| Shallow clone at run time produces a different HEAD than during question authoring | Medium | Medium | Pin repos by commit SHA during authoring; bake SHA into `scripts/clone-bench-repos.sh` | | 3-pass median of same judge hides variance; passes are correlated not independent | Medium | Medium | Cross-family panel or acknowledge limitation explicitly in §9 | | Explorer spawn overhead excluded but material; Token Ratio misleads | Medium | Medium | Include full-session token cost as a second metric; label the narrow metric clearly | @@ -1099,7 +1099,7 @@ If the Graph agent returns zero results on D2 (zero-result rate flagged in §5), 1. **Question authoring source of truth (§12 authoring note):** When you write "questions must cite real symbols, so they are filled in during Phase 0/1" — do you mean you will use the graph to discover those symbols, or will you independently verify them with Grep? If graph-first, you have the bias I described. What is your plan to ensure D1/D3 questions target symbols that Grep can also find? 2. **Judge model identity (§9.4):** What model will be the judge? If it is any Claude model, the same-family self-preference effect applies to every Claude-written Graph and Explorer answer. Have you considered a cross-family judge rotation, or at minimum disclosing the judge model in the report so readers can calibrate? 3. **CROSS edge formation in OTel sub-dirs (§11.1, §15):** Before writing 157 more language chapters, have you actually run `index_repository(mode="cross-repo-intelligence")` on two OTel service sub-dirs and confirmed that CROSS_HTTP_CALLS edges form? This is the load-bearing question for the entire deep-dive block. What is the fallback plan if they don't? -4. **Session continuity (§13):** What happens when the main session context window fills up or hits the usage limit at language 94? Is there a described checkpoint format — e.g., a manifest of completed languages that `clone-bench-repos.sh` can consult to skip already-done languages — or does the whole run restart from zero? +4. **Session continuity (§13):** What happens when the main session context window fills up or hits the usage limit at language 94? Is there a described checkpoint format — e.g., a manifest of completed languages that `scripts/clone-bench-repos.sh` can consult to skip already-done languages — or does the whole run restart from zero? 5. **D5 cross-group comparability (§3, §8):** You aggregate D5 scores across all 159 languages. But D5 for Go means `semantic_query=["dispatch","route"]` surfacing functions from a vector index. D5 for gitignore means "naming-pattern / config↔code links." These are different operations using different graph tools. Do you actually intend the cross-language D5 rollup in §10.1 to be meaningful, or is it cosmetic? 6. **S2 ground truth (§11.2):** "3–5 known near-duplicate function pairs" — how will you construct this set for each of the 9 LSP languages? Will you use the simhash output the indexer already produces, or is this a manual read? A 3-pair sample with no inter-rater agreement cannot support a recall claim. What is the minimum ground-truth size you consider credible? 7. **Token exclusion policy (§5):** If a developer is deciding whether to adopt codebase-memory-mcp, they pay the full session cost, including agent spawn, orientation, and formatting. Why should the reported "Token Ratio" exclude the Explorer's orientation cost? Would you consider reporting both the narrow metric and the full-session metric? diff --git a/docs/index.html b/docs/index.html index 28a09a9d7..62fd7d2b0 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", + "Streamlined MCP tools plus 15 classic tools for structural search, call-path tracing, 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", @@ -67,7 +67,7 @@ "Dead-code detection with entry-point filtering", "Infrastructure-as-code indexing for Dockerfiles, Kubernetes, and Kustomize", "Built-in 3D graph visualization UI", - "Auto-sync background watcher for incremental re-indexing", + "Auto-sync background watcher with configurable reindex policy", "One command configures 43 automatic/conditional client surfaces" ], "author": { @@ -157,7 +157,7 @@ "name": "Which AI coding agents work with codebase-memory-mcp?", "acceptedAnswer": { "@type": "Answer", - "text": "A single install command configures 43 automatic/conditional client surfaces. The 37 detected surfaces are Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob Shell, Pochi, and Pi. Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE, and Sourcegraph Cody are conditional or explicit integrations. Qodo, Warp MCP, JetBrains AI/ACP, GitHub Copilot coding agent, Jules, CodeRabbit, Replit, BLACKBOX AI, Plandex, and SWE-agent require manual, UI, cloud, or repository-managed setup and are not counted among the 43." + "text": "A single install command configures 43 automatic/conditional client surfaces. The 37 detected surfaces are Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob Shell, Pochi, and Pi. Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE, and Sourcegraph Cody are conditional or explicit integrations." } }, { @@ -526,49 +526,11 @@

How do I install codebase-memory-mcp?

"Index this project"

- One command configures 43 automatic/conditional client surfaces. Detected automatically (37): - Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, - Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, - GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI, - GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE, - CodeBuddy Code CLI, IBM Bob Shell, Pochi, and Pi. -

-

- Conditional or explicit (6): Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE, - and Sourcegraph Cody. 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. Windows users run install.ps1. Also available via npm, - pip, Homebrew, Scoop, Winget, Chocolatey, AUR, and go install. -

-

- Lifecycle installation follows documented context contracts: Qoder uses SessionStart, - SubagentStart, and post-Read coverage, including its documented Windows - PowerShell executor. 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. GitLab Duo, Devin, and Factory hooks are withheld on - Windows where no deterministic shell/executor contract is documented. Cline's auto-activating file hooks - are withheld because their context output is not reliably consumed; CodeBuddy's beta hooks are not - auto-installed; Junie's EAP - SessionStart output is documented as ignored; and Cursor context hooks are withheld because - the documented events cannot safely provide race-free MCP context to read-only subagents. -

-

- Documented custom-agent formats receive three exact-owned evidence tiers: Scout for narrow provisional - discovery, Verify as the task-directed default, and Auditor for bounded, paginated, current-generation - verification. Every direct tier calls check_index_coverage for cited paths and relevant scopes, - then reads flagged ranges or skipped files directly. Kiro and Junie use positive-allowlist - --tool-profile scout and --tool-profile analysis server surfaces; - Junie selects dedicated named aliases because its subagent schema filters by server. - Qoder combines named-server selection with exact tier-specific MCP tool IDs. Factory uses - exact registered tool IDs without its additive whole-server mcpServers field. - A foreign Junie alias is preserved and causes the installed profiles to fail closed to parent handoff. - Cursor, Rovo, Augment, Pochi, and Cline use explicit - parent handoff where direct child MCP is unavailable or unsafe; Pochi is limited to readFile. - Neither IBM Bob surface receives an invented hook or custom agent. Amazon Q Developer - IDE defaults to ~/.aws/amazonq/default.json while preserving either existing documented - alternative. + One command configures 43 automatic/conditional client surfaces. Detected clients receive only + their documented MCP, instruction, skill, agent, and fail-open context-hook surfaces; conditional + clients are written only when their platform, marker, or explicit existing config proves activation. + Windows users run install.ps1. Also available via + npm, pip, Homebrew, Scoop, Winget, Chocolatey, AUR, and go install.

@@ -727,19 +689,19 @@

Infrastructure-as-code indexing

Auto-sync

-

A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.

+

A background watcher detects git changes and re-indexes when configured. The reindex policy controls whether refreshes use full or incremental indexing.

Team-shared graph artifact

-

Commit one zstd-compressed snapshot (.codebase-memory/graph.db.zst); teammates bootstrap from it and skip the full reindex.

+

Commit one zstd-compressed snapshot (.codebase-memory/graph.db.zst); teammates can bootstrap from it before any configured refresh.

3D graph visualization

An optional UI binary 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.

+

Streamlined + classic MCP tools

+

Default tools include search_graph, trace_path, query_graph, search_code, and get_code. Classic mode exposes 15 individual tools including index_repository, get_architecture, get_code_snippet, and manage_adr.

Cypher graph queries

@@ -804,9 +766,9 @@

Do I need Docker or a runtime?

(arm64/amd64), and Windows (amd64).

How does it stay up to date as I edit code?

-

A background watcher detects file changes and re-indexes incrementally — typically a sub-millisecond - no-op when nothing changed. You only run a manual index for the first build or after a large - git pull.

+

A background watcher detects git changes and can re-index automatically when configured. + The default refresh path favors correctness with a full atomic rebuild; incremental reindexing + remains an explicit policy setting.

Why is there no built-in LLM?

Other code-graph tools embed an LLM to translate natural language into graph queries, which means diff --git a/docs/llms.txt b/docs/llms.txt index 32eb41640..1fd018ee3 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -7,13 +7,12 @@ - License: MIT, open source. - Languages: 158 (158 vendored tree-sitter grammars compiled into the binary). - Hybrid LSP type resolution: 9 language families (Python, TypeScript/JavaScript/JSX/TSX, PHP, C#, Go, C/C++, Java, Kotlin, Rust) — a lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers including tsserver, pyright, gopls, Roslyn, Eclipse JDT, and rust-analyzer. -- MCP tools: 15 (search_graph incl. semantic_query vector search, trace_path (alias: trace_call_path), check_index_coverage, query_graph (Cypher), detect_changes, get_architecture, get_code_snippet, manage_adr, and more). +- MCP tools: streamlined default surface plus 15 classic tools. Defaults: search_graph, query_graph, search_code, trace_path, get_code, plus _hidden_tools discovery. Classic mode includes index_repository, get_architecture, get_code_snippet, detect_changes, manage_adr, index_dependencies, and more. - Semantic search: natural-language code discovery via bundled nomic-embed-code embeddings (768-dim, compiled into the binary); 11-signal combined scoring; fully local, no API key. - Semantic & similarity edges: SEMANTICALLY_RELATED (vocabulary-mismatch matches) and SIMILAR_TO (MinHash + LSH near-clone / duplicate detection). - Cross-repo intelligence: CROSS_* edges link nodes across multiple repos indexed in one store; multi-galaxy 3D layout and cross-repo architecture summary. - Cross-service linking: HTTP route ↔ call-site matching, plus gRPC/GraphQL/tRPC detection and pub/sub channels (EMITS/LISTENS_ON for Socket.IO, EventEmitter, generic buses). - Supported agents: 43 automatic/conditional client surfaces (37 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, and Sourcegraph Cody. -- Agent profiles: documented custom-agent formats receive Scout (fast/provisional), Verify (default/task-directed), and Auditor (bounded/full verification) definitions. Every direct tier checks exact path/scope coverage with check_index_coverage and falls back to source for flagged gaps; unsafe child-MCP formats use explicit parent handoff. Kiro and Junie use positive-allowlist Scout/Analysis server profiles (7/11 tools); Qoder combines named-server selection with exact tier-specific MCP tool IDs, while Factory uses exact registered IDs without additive whole-server exposure. Foreign Junie aliases are preserved and force parent handoff. - Performance: Linux kernel (28M LOC, 75K files) full index in 3 minutes → 4.81M nodes, 7.72M edges; Cypher queries in under 1ms. - Distribution: single static C binary; also npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, and `go install`. diff --git a/docs/schema/benchmark-facts-v1.schema.json b/docs/schema/benchmark-facts-v1.schema.json new file mode 100644 index 000000000..7e576a975 --- /dev/null +++ b/docs/schema/benchmark-facts-v1.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "benchmark-facts-v1.schema.json", + "title": "Codebase Memory benchmark fact bundle", + "type": "object", + "required": ["$schema", "schema_version", "runs", "steps", "results", "artifacts"], + "properties": { + "$schema": {"const": "docs/schema/benchmark-facts-v1.schema.json"}, + "schema_version": {"const": 1}, + "runs": { + "description": "Run-level identity and measurement conditions; exactly one row per fact bundle.", + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": {"$ref": "#/$defs/run"} + }, + "steps": { + "description": "Measured operation occurrences. Rows may overlap in wall time and are not additive unless a report proves serial execution.", + "type": "array", + "items": {"$ref": "#/$defs/step"} + }, + "results": { + "description": "Correctness, quality, and instrumentation outcomes for the run.", + "type": "array", + "items": {"$ref": "#/$defs/result"} + }, + "artifacts": { + "description": "Content-identified files retained as measurement evidence.", + "type": "array", + "items": {"$ref": "#/$defs/artifact"} + } + }, + "additionalProperties": false, + "$defs": { + "unknown": { + "description": "A value the measurement source did not record; reason states the missing evidence.", + "type": "object", + "required": ["status", "reason"], + "properties": { + "status": {"const": "unknown"}, + "reason": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "runId": {"type": "string", "pattern": "^[0-9a-f]{24}$"}, + "run": { + "type": "object", + "required": [ + "run_id", "lifecycle_id", "generated_at_utc", "mode", "implementation", + "harness", "host", "measurement_checkout", "capabilities", "scope", "cache", "legacy_import" + ], + "properties": { + "run_id": {"$ref": "#/$defs/runId", "description": "Content-derived identity for this measured lifecycle."}, + "lifecycle_id": {"$ref": "#/$defs/runId", "description": "Identity of the user-observable process-to-gate lifecycle; equal to run_id in schema version 1."}, + "generated_at_utc": {"description": "Recorded report completion time, or an explicit unknown fact."}, + "mode": {"type": "string", "minLength": 1, "description": "Benchmark workload/report family."}, + "cell_identity": {"description": "Immutable experiment-cell identity, or an explicit unknown fact for standalone and legacy runs."}, + "cell_label": {"description": "Human-readable experiment-cell label, or an explicit unknown fact."}, + "repetition": {"description": "One-based repetition declared by the experiment, or an explicit unknown fact."}, + "implementation": {"type": "object", "description": "Candidate revision, revision provenance, binary identity, and build metadata."}, + "harness": {"type": "object", "description": "Benchmark script path, SHA-256, and fact-schema version."}, + "host": {"type": "object", "description": "Host facts recorded by the measurement process, or an explicit unknown fact."}, + "measurement_checkout": {"type": "object", "description": "Git checkout that executed the harness; it is not evidence of the candidate binary revision."}, + "capabilities": {"type": "object", "description": "Resolved capability/configuration values plus completeness and provenance."}, + "scope": {"type": "object", "description": "Workload identity, corpus or fixture bounds, and mutation size."}, + "cache": {"type": "object", "description": "Known process, graph, dependency, OS, parser, and fixture cache states."}, + "legacy_import": {"type": "boolean", "description": "True when a retained report was normalized without recorded measurement-process context."} + }, + "additionalProperties": false + }, + "step": { + "type": "object", + "required": [ + "run_id", "step_id", "occurrence_id", "source_path", "parent_occurrence_id", + "dependency_occurrence_ids", "elapsed_ms", "monotonic_start_ns", + "monotonic_end_ns", "cpu_ms", "cpu_scope", "queue_wait_ms", + "thread_or_worker_id", "critical_path", "peak_rss_mb", "work_counters", + "provenance" + ], + "properties": { + "run_id": {"$ref": "#/$defs/runId"}, + "step_id": {"type": "string", "minLength": 1, "description": "Stable operation-class label; multiple occurrences may share it."}, + "occurrence_id": {"type": "string", "pattern": "^[0-9a-f]{24}$", "description": "Identity of this operation occurrence within the run."}, + "source_path": {"type": "string", "description": "JSON path from which the measurement was normalized."}, + "parent_occurrence_id": {"type": ["string", "null"], "description": "Containing occurrence; containment does not imply serial execution."}, + "dependency_occurrence_ids": {"type": "array", "items": {"type": "string"}, "description": "Recorded prerequisite occurrences; an empty array means no dependency evidence was recorded."}, + "elapsed_ms": {"type": "number", "minimum": 0, "description": "Wall-clock duration. Overlapping occurrence durations must not be summed."}, + "monotonic_start_ns": {"description": "Monotonic start timestamp or an explicit unknown fact."}, + "monotonic_end_ns": {"description": "Monotonic end timestamp or an explicit unknown fact."}, + "cpu_ms": {"description": "CPU time consumed by cpu_scope, or an explicit unknown fact."}, + "cpu_scope": {"type": "string", "description": "Entity covered by cpu_ms, such as thread, process, or process tree."}, + "queue_wait_ms": {"description": "Runnable-to-execution delay or an explicit unknown fact."}, + "thread_or_worker_id": {"description": "Recorded execution resource identity or an explicit unknown fact."}, + "critical_path": {"description": "Whether and how this occurrence lies on the measured dependency critical path, or an explicit unknown fact."}, + "peak_rss_mb": {"description": "Peak resident memory attributable to the occurrence, or an explicit unknown fact."}, + "work_counters": {"type": "object", "description": "Operation-specific item counts with named units."}, + "provenance": {"type": "string", "description": "Measurement source or normalization rule that produced the row."} + }, + "additionalProperties": false + }, + "result": { + "type": "object", + "required": ["run_id", "result_id", "kind", "status", "value", "provenance"], + "properties": { + "run_id": {"$ref": "#/$defs/runId"}, + "result_id": {"type": "string", "minLength": 1}, + "kind": {"type": "string", "minLength": 1}, + "status": {"enum": ["passed", "failed", "unknown", "skipped"]}, + "value": {}, + "provenance": {"type": "string"} + }, + "additionalProperties": false + }, + "artifact": { + "type": "object", + "required": [ + "run_id", "artifact_id", "artifact_type", "path", "sha256", "size_bytes", + "schema_version", "cleanup_status" + ], + "properties": { + "run_id": {"$ref": "#/$defs/runId"}, + "artifact_id": {"type": "string", "pattern": "^[0-9a-f]{24}$"}, + "artifact_type": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "size_bytes": {}, + "schema_version": {}, + "cleanup_status": {"type": "string"} + }, + "additionalProperties": false + } + } +} diff --git a/graph-ui/tsconfig.tsbuildinfo b/graph-ui/tsconfig.tsbuildinfo index 0da71675b..b5221e719 100644 --- a/graph-ui/tsconfig.tsbuildinfo +++ b/graph-ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/install.ps1 b/install.ps1 index 890dfd4f8..656e7d911 100644 --- a/install.ps1 +++ b/install.ps1 @@ -136,6 +136,7 @@ $Url = "$BaseUrl/$Archive" # Download $TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-install-$(Get-Random)" New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null +try { Write-Host "Downloading $Archive..." try { @@ -322,14 +323,16 @@ if (Test-Path -LiteralPath $DownloadedInstaller -PathType Leaf) { } } -# Verify +# Verify. The launcher's activation transaction already staged, swapped, and +# rolled back the binary under its own ownership and DACL checks, so this step +# only confirms the activated executable runs; there is no script-level +# rename-aside copy left to restore. try { $ver = & $Dest --version 2>&1 - if ($LASTEXITCODE -ne 0) { throw "installed binary exited with $LASTEXITCODE" } + if ($LASTEXITCODE -ne 0) { throw "installed binary exited with code $LASTEXITCODE" } Write-Host "Installed: $ver" } catch { - Write-Host "error: installed binary failed to run" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir + Write-Host "error: installed binary failed to run: $_" -ForegroundColor Red exit 1 } @@ -343,8 +346,8 @@ if ($SkipConfig) { # coordinated activation lease. Do not perform a second registry mutation here # after running sessions have been allowed to restart. -# Cleanup -Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue - Write-Host "" Write-Host "Done! Restart your terminal and coding agent to start using codebase-memory-mcp." +} finally { + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue +} diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index ee1749cdb..ef0582a1b 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -23,6 +23,7 @@ #if defined(CBM_BIND_TS_ALLOCATOR) && CBM_BIND_TS_ALLOCATOR #include "sqlite3.h" // sqlite3_mem_methods, sqlite3_config, SQLITE_CONFIG_MALLOC — bind sqlite to mimalloc #endif +#include #include // uint32_t, uint64_t, int64_t #include #include @@ -39,12 +40,9 @@ static _Atomic uint64_t total_preprocess_ns = 0; static _Atomic uint64_t total_files_preprocessed = 0; static _Atomic uint64_t total_files = 0; -// C/C++ preprocessor #define macros are extracted as Macro nodes (#375). On a -// macro-dense codebase (e.g. the Linux kernel: ~2.4M macros, 49% of all nodes) -// this is the dominant extraction cost, so it is gated to the full/advanced -// index modes. Default ON to preserve behavior for direct callers/tests; the -// pipeline sets it from the index mode before extraction. Set once pre-extract, -// read-only during, so a relaxed atomic is sufficient. +// Default for direct cbm_extract_file() callers. Pipelines pass this per call +// via cbm_extract_file_with_options(), because MCP can run multiple pipelines +// with different modes in the same process. static _Atomic int g_extract_macros = 1; void cbm_set_macro_extraction(int enabled) { atomic_store_explicit(&g_extract_macros, enabled ? 1 : 0, memory_order_relaxed); @@ -345,20 +343,36 @@ void cbm_alloc_init(void) { // --- Init/Shutdown --- -static int cbm_initialized = 0; +enum { + CBM_LIB_INIT_UNINIT = 0, + CBM_LIB_INIT_INITIALIZING = 1, + CBM_LIB_INIT_READY = 2, +}; + +static _Atomic int cbm_init_state = CBM_LIB_INIT_UNINIT; int cbm_init(void) { - if (cbm_initialized) { + if (atomic_load_explicit(&cbm_init_state, memory_order_acquire) == CBM_LIB_INIT_READY) { return 0; } - enum { CBM_INIT_DONE = 1 }; - cbm_initialized = CBM_INIT_DONE; + + int expected = CBM_LIB_INIT_UNINIT; + if (!atomic_compare_exchange_strong_explicit(&cbm_init_state, &expected, + CBM_LIB_INIT_INITIALIZING, memory_order_acq_rel, + memory_order_acquire)) { + while (atomic_load_explicit(&cbm_init_state, memory_order_acquire) != CBM_LIB_INIT_READY) { + /* Another thread is completing library initialization. */ + } + return 0; + } + /* Defense-in-depth allocator binds (idempotent). main() calls cbm_alloc_init * first; this covers non-main entry points (pipeline passes call cbm_init). * For sqlite the SQLITE_CONFIG_MALLOC bind only takes effect if it runs * before sqlite initializes — main() guarantees that ordering; here it is a * best-effort idempotent re-assert for paths that never hit main(). */ cbm_alloc_init(); + atomic_store_explicit(&cbm_init_state, CBM_LIB_INIT_READY, memory_order_release); return 0; } @@ -384,7 +398,7 @@ void cbm_shutdown(void) { // Clean up thread-local parser for the calling thread. // Note: other threads' TLS parsers are freed when those threads exit. cbm_destroy_thread_parser(); - cbm_initialized = 0; + atomic_store_explicit(&cbm_init_state, CBM_LIB_INIT_UNINIT, memory_order_release); } // --- Bottleneck call-name classification (language-agnostic heuristics) --- @@ -747,41 +761,87 @@ static bool cbm_source_nesting_exceeds(const char *source, int source_len, int c return false; } -/* Best-effort parse-coverage collection (#963). Walks only the has_error paths - * of the tree and records the 1-based line ranges of the TOP-MOST ERROR/MISSING - * nodes (does not descend into an error subtree — one range per failed region). - * Bounded by CBM_MAX_ERROR_REGIONS so pathological input can't blow up the - * output. The ranges mark where constructs were dropped; they are a detection - * aid, never a completeness proof. */ -#define CBM_MAX_ERROR_REGIONS 64 +static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len, + CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, const char **include_paths, + bool extract_macros, const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table); + +typedef struct { + uint32_t start; + uint32_t end; +} cbm_line_region_t; + typedef struct { - uint32_t starts[CBM_MAX_ERROR_REGIONS]; - uint32_t ends[CBM_MAX_ERROR_REGIONS]; + cbm_line_region_t *items; int count; + int capacity; } cbm_error_regions_t; -static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) { - if (acc->count >= CBM_MAX_ERROR_REGIONS) { - return; +/* Best-effort parse-coverage collection (#963). Walks only the has_error paths + * and records 1-based ranges for TOP-MOST ERROR/MISSING nodes. Geometric + * storage makes collection O(E) amortized time and O(E) memory for E regions; + * E is bounded by the already-materialized parse tree rather than a silent + * prefix cap. */ +static bool cbm_error_regions_append(cbm_error_regions_t *acc, uint32_t start, uint32_t end) { + if (!acc) { + return false; } - acc->starts[acc->count] = ts_node_start_point(n).row + 1; - acc->ends[acc->count] = ts_node_end_point(n).row + 1; - acc->count++; + if (acc->count >= acc->capacity) { + if (acc->capacity > INT_MAX / CBM_SZ_2) { + return false; + } + int next_capacity = acc->capacity ? acc->capacity * CBM_SZ_2 : CBM_SZ_64; + if ((size_t)next_capacity > SIZE_MAX / sizeof(*acc->items)) { + return false; + } + cbm_line_region_t *grown = realloc(acc->items, (size_t)next_capacity * sizeof(*acc->items)); + if (!grown) { + return false; + } + acc->items = grown; + acc->capacity = next_capacity; + } + acc->items[acc->count++] = (cbm_line_region_t){.start = start, .end = end}; + return true; } -static void cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc) { - if (acc->count >= CBM_MAX_ERROR_REGIONS) { - return; - } +static bool cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) { + return cbm_error_regions_append(acc, ts_node_start_point(n).row + 1, + ts_node_end_point(n).row + 1); +} + +static bool cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc) { uint32_t k = ts_node_child_count(n); - for (uint32_t i = 0; i < k && acc->count < CBM_MAX_ERROR_REGIONS; i++) { + for (uint32_t i = 0; i < k; i++) { TSNode c = ts_node_child(n, i); if (ts_node_is_missing(c) || strcmp(ts_node_type(c), "ERROR") == 0) { - cbm_error_regions_push(acc, c); /* top-most region; do not descend */ - } else if (ts_node_has_error(c)) { - cbm_collect_error_regions(c, acc); + if (!cbm_error_regions_push(acc, c)) { + return false; + } + } else if (ts_node_has_error(c) && !cbm_collect_error_regions(c, acc)) { + return false; } } + return true; +} + +static void cbm_error_regions_destroy(cbm_error_regions_t *regions) { + if (!regions) { + return; + } + free(regions->items); + *regions = (cbm_error_regions_t){0}; +} + +static int cbm_line_region_compare(const void *lhs, const void *rhs) { + const cbm_line_region_t *a = lhs; + const cbm_line_region_t *b = rhs; + if (a->start != b->start) { + return (a->start > b->start) - (a->start < b->start); + } + return (a->end > b->end) - (a->end < b->end); } /* Recovery subtraction (#963): tree-sitter error recovery plus the @@ -793,46 +853,46 @@ static void cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc) { * 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 }; - uint32_t starts[MAX_COVER_DEFS]; - uint32_t ends[MAX_COVER_DEFS]; - int n = 0; - for (int i = 0; i < defs->count && n < MAX_COVER_DEFS; i++) { +static bool cbm_collect_recovery_regions(const CBMDefArray *defs, cbm_error_regions_t *recovered) { + for (int i = 0; i < defs->count; i++) { const CBMDefinition *d = &defs->items[i]; if (!d->label || strcmp(d->label, "Module") == 0 || strcmp(d->label, "Package") == 0) { continue; } - if (d->start_line < rs || d->start_line > re) { - continue; /* recovery evidence must originate inside the region */ + uint32_t end = d->end_line < d->start_line ? d->start_line : d->end_line; + if (!cbm_error_regions_append(recovered, d->start_line, end)) { + return false; } - starts[n] = d->start_line; - ends[n] = d->end_line < d->start_line ? d->start_line : d->end_line; - n++; } - if (n == 0) { + if (recovered->count > 1) { + qsort(recovered->items, (size_t)recovered->count, sizeof(*recovered->items), + cbm_line_region_compare); + } + return true; +} + +static bool cbm_region_is_recovered(uint32_t rs, uint32_t re, + const cbm_error_regions_t *recovered) { + if (!recovered || recovered->count <= 0) { return false; } - /* Insertion-sort by start, then sweep for gaps in [rs, re]. */ - for (int i = 1; i < n; i++) { - uint32_t s = starts[i]; - uint32_t e = ends[i]; - int j = i - 1; - while (j >= 0 && starts[j] > s) { - starts[j + 1] = starts[j]; - ends[j + 1] = ends[j]; - j--; + int lo = 0; + int hi = recovered->count; + while (lo < hi) { + int mid = lo + (hi - lo) / CBM_SZ_2; + if (recovered->items[mid].start < rs) { + lo = mid + SKIP_ONE; + } else { + hi = mid; } - starts[j + 1] = s; - ends[j + 1] = e; } uint32_t covered_to = rs - 1; - for (int i = 0; i < n; i++) { - if (starts[i] > covered_to + 1) { + for (int i = lo; i < recovered->count && recovered->items[i].start <= re; i++) { + if (covered_to != UINT32_MAX && recovered->items[i].start > covered_to + 1) { return false; /* uncovered gap */ } - if (ends[i] > covered_to) { - covered_to = ends[i]; + if (recovered->items[i].end > covered_to) { + covered_to = recovered->items[i].end; } } return covered_to >= re; @@ -971,15 +1031,23 @@ static bool cbm_remap_preprocessed_def(CBMDefinition *def, const CBMPreprocessed } static void cbm_subtract_recovered_regions(cbm_error_regions_t *regs, const CBMDefArray *defs) { + /* One shared sort replaces the former per-region insertion sort: + * O(D log D + E log D + D) for disjoint top-level error regions, with + * O(D) auxiliary storage for D extracted definitions and E errors. */ + cbm_error_regions_t recovered = {0}; + if (!cbm_collect_recovery_regions(defs, &recovered)) { + cbm_error_regions_destroy(&recovered); + return; /* conservative: retain every parse-partial range */ + } 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++; + cbm_line_region_t region = regs->items[i]; + if (!cbm_region_is_recovered(region.start, region.end, &recovered)) { + regs->items[kept++] = region; } } regs->count = kept; + cbm_error_regions_destroy(&recovered); } /* #1071: a function-like macro invocation whose argument is a type token @@ -1065,13 +1133,11 @@ static void cbm_subtract_macro_invocation_regions(cbm_error_regions_t *regs, int src_len) { int kept = 0; for (int i = 0; i < regs->count; i++) { - bool benign = - cbm_span_is_macro_invocation(src, src_len, regs->starts[i], regs->ends[i], defs) && - cbm_region_inside_callable(regs->starts[i], regs->ends[i], defs); + cbm_line_region_t region = regs->items[i]; + bool benign = cbm_span_is_macro_invocation(src, src_len, region.start, region.end, defs) && + cbm_region_inside_callable(region.start, region.end, defs); if (!benign) { - regs->starts[kept] = regs->starts[i]; - regs->ends[kept] = regs->ends[i]; - kept++; + regs->items[kept++] = region; } } regs->count = kept; @@ -1083,14 +1149,17 @@ static const char *cbm_error_ranges_str(CBMArena *a, const cbm_error_regions_t * return NULL; } enum { RANGE_MAX = 24 }; /* "4294967295-4294967295," */ + if ((size_t)regs->count > SIZE_MAX / RANGE_MAX) { + return NULL; + } char *buf = (char *)cbm_arena_alloc(a, (size_t)regs->count * RANGE_MAX); if (!buf) { return NULL; } size_t off = 0; for (int i = 0; i < regs->count; i++) { - off += (size_t)snprintf(buf + off, RANGE_MAX, "%s%u-%u", i ? "," : "", regs->starts[i], - regs->ends[i]); + off += (size_t)snprintf(buf + off, RANGE_MAX, "%s%u-%u", i ? "," : "", regs->items[i].start, + regs->items[i].end); } return buf; } @@ -1102,9 +1171,32 @@ static const char *cbm_error_ranges_str(CBMArena *a, const cbm_error_regions_t * CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage language, const char *project, const char *rel_path, int64_t timeout_micros, const char **extra_defines, const char **include_paths) { - CBMFileResult *r = - cbm_extract_file_ex(source, source_len, language, project, rel_path, timeout_micros, - extra_defines, include_paths, NULL, NULL); + return cbm_extract_file_with_options(source, source_len, language, project, rel_path, + timeout_micros, extra_defines, include_paths, + cbm_macro_extraction_enabled() != 0); +} + +CBMFileResult *cbm_extract_file_with_options(const char *source, int source_len, + CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, const char **include_paths, + bool extract_macros) { + return cbm_extract_file_with_options_ex(source, source_len, language, project, rel_path, + timeout_micros, extra_defines, include_paths, + extract_macros, NULL, NULL); +} + +CBMFileResult *cbm_extract_file_with_options_ex(const char *source, int source_len, + CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, + const char **include_paths, bool extract_macros, + const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table) { + CBMFileResult *r = cbm_extract_file_impl(source, source_len, language, project, rel_path, + timeout_micros, extra_defines, include_paths, + extract_macros, macro_table, return_type_table); + cbm_index_mark_done(rel_path); return r; } @@ -1113,6 +1205,17 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua int64_t timeout_micros, const char **extra_defines, const char **include_paths, const CBMMacroTable *macro_table, const CBMReturnTypeTable *return_type_table) { + return cbm_extract_file_with_options_ex( + source, source_len, language, project, rel_path, timeout_micros, extra_defines, + include_paths, cbm_macro_extraction_enabled() != 0, macro_table, return_type_table); +} + +static CBMFileResult *cbm_extract_file_impl(const char *source, int source_len, + CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, const char **include_paths, + bool extract_macros, const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table) { // Allocate result on heap (arena inside for all string data) enum { SINGLE = 1 }; CBMFileResult *result = (CBMFileResult *)calloc(SINGLE, sizeof(CBMFileResult)); @@ -1229,6 +1332,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua .rel_path = rel_path, .module_qn = result->module_qn, .root = root, + .extract_macros = extract_macros, .macro_table = macro_table, .return_type_table = return_type_table, }; @@ -1346,11 +1450,15 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua .rel_path = rel_path, .module_qn = result->module_qn, .root = pp_root, + .extract_macros = extract_macros, + .macro_table = macro_table, + .return_type_table = return_type_table, }; - // Re-run unified extraction on expanded source. - // This adds macro-expanded calls; duplicates with original calls are - // harmless (pipeline deduplicates by caller+callee). - cbm_extract_unified(&pp_ctx); + // Re-run only call extraction on expanded source. Other metadata + // from included/expanded text would be attributed to this file. + // Duplicated calls are harmless (pipeline deduplicates by + // caller+callee). + cbm_extract_unified_calls_only(&pp_ctx); // Also run LSP on expanded source for additional type-resolved // calls (language is already C/C++/CUDA — checked in enclosing @@ -1369,9 +1477,9 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua * the raw source line, and whose QN the raw pass did not * already extract. */ if (ts_node_has_error(root)) { - cbm_error_regions_t raw_regs = {{0}, {0}, 0}; - cbm_collect_error_regions(root, &raw_regs); - if (raw_regs.count > 0) { + cbm_error_regions_t raw_regs = {0}; + bool raw_regions_complete = cbm_collect_error_regions(root, &raw_regs); + if (raw_regions_complete && raw_regs.count > 0) { int defs_before = result->defs.count; cbm_extract_definitions(&pp_ctx); int w = defs_before; @@ -1380,8 +1488,8 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua bool adopt = false; 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] && - d->end_line >= raw_regs.starts[rj]) { + if (d->start_line <= raw_regs.items[rj].end && + d->end_line >= raw_regs.items[rj].start) { adopt = true; } } @@ -1407,6 +1515,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua } result->defs.count = w; } + cbm_error_regions_destroy(&raw_regs); } ts_tree_delete(pp_tree); @@ -1524,21 +1633,27 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua * miss, and a fully recovered file is not flagged at all. Detection aid * only: the absence of this flag is NOT a completeness guarantee. */ if (ts_node_has_error(root)) { - cbm_error_regions_t regs = {{0}, {0}, 0}; - if (strcmp(ts_node_type(root), "ERROR") == 0) { - cbm_error_regions_push(®s, root); /* whole file unparseable */ - } else { - cbm_collect_error_regions(root, ®s); + cbm_error_regions_t regs = {0}; + bool regions_complete = strcmp(ts_node_type(root), "ERROR") == 0 + ? cbm_error_regions_push(®s, root) + : cbm_collect_error_regions(root, ®s); + if (regions_complete) { + cbm_subtract_recovered_regions(®s, &result->defs); + /* #1071: don't flag a benign function-like-macro call (defined in-file) + * that tree-sitter can't parse without the preprocessor. */ + cbm_subtract_macro_invocation_regions(®s, &result->defs, source, source_len); } - cbm_subtract_recovered_regions(®s, &result->defs); - /* #1071: don't flag a benign function-like-macro call (defined in-file) - * that tree-sitter can't parse without the preprocessor. */ - cbm_subtract_macro_invocation_regions(®s, &result->defs, source, source_len); - if (regs.count > 0) { + if (!regions_complete) { + result->parse_incomplete = true; + result->error_region_count = 0; + result->error_ranges = + cbm_arena_strdup(a, "unknown (error-region collection allocation failed)"); + } else if (regs.count > 0) { result->parse_incomplete = true; result->error_region_count = regs.count; result->error_ranges = cbm_error_ranges_str(a, ®s); } + cbm_error_regions_destroy(®s); } result->imports_count = result->imports.count; diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 28de37fb7..c89029310 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -240,6 +240,8 @@ typedef struct { 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) + bool is_macro_invocation; // call syntax is a language macro invocation (e.g. Rust + // `matches!`), not an ordinary function/member call bool is_method; // method/member call with a non-self receiver. Perl: // arrow/method call ($obj->m). TS/JS/TSX: member call // x.foo() whose receiver is not this/super. Default false. @@ -530,11 +532,12 @@ typedef struct { const char *rel_path; const char *module_qn; TSNode root; + bool extract_macros; // C/C++ #define Macro nodes for full mode EFCache ef_cache; // enclosing function cache const char *enclosing_class_qn; // for nested class QN computation CBMStringConstantMap string_constants; // module-level NAME = "value" pairs - const CBMMacroTable *macro_table; // ObjectScript $$$macro table (NULL if none) - const CBMReturnTypeTable *return_type_table; // ObjectScript method return types (NULL if none) + const CBMMacroTable *macro_table; // ObjectScript macros, or NULL + const CBMReturnTypeTable *return_type_table; // ObjectScript return types, or NULL } CBMExtractCtx; // --- Public API --- @@ -583,6 +586,21 @@ CBMFileResult *cbm_extract_file(const char *source, int source_len, CBMLanguage const char **extra_defines, // NULL-terminated, or NULL const char **include_paths // NULL-terminated, or NULL ); +CBMFileResult *cbm_extract_file_with_options(const char *source, int source_len, + CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, const char **include_paths, + bool extract_macros); + +/* Canonical compositional entry point for pipeline extraction. Every option is + * explicit so concurrent pipelines never depend on process-global settings. */ +CBMFileResult *cbm_extract_file_with_options_ex(const char *source, int source_len, + CBMLanguage language, const char *project, + const char *rel_path, int64_t timeout_micros, + const char **extra_defines, + const char **include_paths, bool extract_macros, + const CBMMacroTable *macro_table, + const CBMReturnTypeTable *return_type_table); // Pipeline-internal variant of cbm_extract_file() carrying ObjectScript // per-project tables (macro table + method-return-type table). The public @@ -628,14 +646,19 @@ uint64_t cbm_get_preprocess_ns(void); uint64_t cbm_get_files_preprocessed(void); void cbm_reset_profile(void); -// Toggle C/C++ preprocessor Macro-node extraction (#375). The pipeline enables -// it only for full/advanced index modes (it dominates extraction on macro-dense -// codebases). Default ON. Set before extraction; read-only during. +// Toggle the default for direct cbm_extract_file() callers. Pipelines pass this +// explicitly via cbm_extract_file_with_options(), avoiding cross-pipeline races. void cbm_set_macro_extraction(int enabled); int cbm_macro_extraction_enabled(void); // --- Internal helpers used by extractors --- +// True for labels that describe user-defined types and can be registry targets. +bool cbm_label_is_type_like(const char *label); + +// True for definition labels where duplicate QNs should keep the richest source span. +bool cbm_label_uses_source_span_selection(const char *label); + // Growable array push functions (arena-allocated, no individual free needed). void cbm_defs_push(CBMDefArray *arr, CBMArena *a, CBMDefinition def); void cbm_calls_push(CBMCallArray *arr, CBMArena *a, CBMCall call); diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f7ee7bafa..25d1ebc43 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -260,12 +260,8 @@ static char *extract_callee_from_fields(CBMArena *a, TSNode node, const char *so strcmp(fk, "value_identifier") == 0 || strcmp(fk, "value_identifier_path") == 0) { return cbm_node_text(a, func_node, source); } - // C++ explicit template call f(args): the `function` field is a - // template_function whose `name` child is the bare callee (identifier - // "identity" or qualified_identifier "ns::f"). Without this the whole - // "identity" text would never be produced as a textual callee, so - // no CALLS edge — and the LSP's lsp_template resolution has nothing to - // attach to. Return the name child so the join recovers the bare method. + // C++ f(args): use template_function's bare `name` child so the + // textual call joins the LSP's template resolution. if (strcmp(fk, "template_function") == 0) { TSNode tname = ts_node_child_by_field_name(func_node, TS_FIELD("name")); if (!ts_node_is_null(tname)) { @@ -319,10 +315,23 @@ static char *extract_callee_from_fields(CBMArena *a, TSNode node, const char *so return method; } + TSNode callee_node = ts_node_child_by_field_name(node, TS_FIELD("callee")); + if (!ts_node_is_null(callee_node)) { + while (ts_node_named_child_count(callee_node) == 1) { + const char *ck = ts_node_type(callee_node); + if (strcmp(ck, "value") != 0 && strcmp(ck, "var") != 0 && + strcmp(ck, "expression") != 0) { + break; + } + callee_node = ts_node_named_child(callee_node, 0); + } + return cbm_node_text(a, callee_node, source); + } + return NULL; } -// Haskell/OCaml: extract callee from apply/infix nodes. +// Haskell/OCaml/PureScript: extract callee from apply/infix nodes. static char *extract_fp_callee(CBMArena *a, TSNode node, const char *source, const char *nk) { if (strcmp(nk, "apply") == 0 || strcmp(nk, "application_expression") == 0 || strcmp(nk, "exp_apply") == 0) { @@ -651,17 +660,43 @@ static char *extract_dart_callee(CBMArena *a, TSNode node, const char *source, c return NULL; } -// SCSS: an `@include foo;` is an include_statement whose callee is its -// `identifier` child (the mixin name). +static bool agda_expr_belongs_to_signature(TSNode node) { + TSNode cur = node; + while (!ts_node_is_null(cur)) { + if (strcmp(ts_node_type(cur), "function") == 0) { + TSNode lhs = cbm_find_child_by_kind(cur, "lhs"); + return !ts_node_is_null(lhs) && + !ts_node_is_null(cbm_find_child_by_kind(lhs, "function_name")); + } + cur = ts_node_parent(cur); + } + return false; +} + +// Agda function application parses as an expr with the callee in child 0. +static char *extract_agda_callee(CBMArena *a, TSNode node, const char *source, const char *nk) { + if (strcmp(nk, "module_application") == 0) { + return extract_callee_from_fields(a, node, source); + } + if (strcmp(nk, "expr") != 0 || ts_node_named_child_count(node) < 2 || + agda_expr_belongs_to_signature(node)) { + return NULL; + } + TSNode head = ts_node_named_child(node, 0); + if (strcmp(ts_node_type(head), "atom") == 0 && ts_node_named_child_count(head) > 0) { + head = ts_node_named_child(head, 0); + } + return cbm_node_text(a, head, source); +} + +// SCSS includes and @function calls store callees as named children rather +// than the generic function/name fields. static char *extract_scss_callee(CBMArena *a, TSNode node, const char *source, const char *nk) { if (strcmp(nk, "include_statement") == 0) { TSNode id = cbm_find_child_by_kind(node, "identifier"); return ts_node_is_null(id) ? NULL : cbm_node_text(a, id, source); } - /* SCSS @function call `double($x)` is a call_expression whose callee is a - * `function_name` child (there is no `function` field), so the generic - * field-based resolver returns NULL and the call is dropped — no CALLS edge - * to the in-file @function. */ + /* `double($x)` has no generic `function` field. */ if (strcmp(nk, "call_expression") == 0) { TSNode fn = cbm_find_child_by_kind(node, "function_name"); if (!ts_node_is_null(fn)) { @@ -765,9 +800,13 @@ static char *extract_nickel_callee(CBMArena *a, TSNode node, const char *source, if (!ts_node_is_null(parent) && strcmp(ts_node_type(parent), "applicative") == 0) { return NULL; } - enum { NICKEL_APPLY_DEPTH = 8 }; + /* + * Only the outermost applicative reaches this walk, so following the full + * function-side chain is O(D) for curried depth D rather than repeated + * quadratic work. Stop on a null/self edge, not an arbitrary semantic cap. + */ TSNode cur = node; - for (int depth = 0; depth < NICKEL_APPLY_DEPTH && !ts_node_is_null(cur); depth++) { + while (!ts_node_is_null(cur)) { const char *ck = ts_node_type(cur); if (strcmp(ck, "ident") == 0) { return cbm_node_text(a, cur, source); @@ -872,8 +911,9 @@ static char *extract_nasm_callee(CBMArena *a, TSNode node, const char *source, c return NULL; } char *m = cbm_node_text(a, mnem, source); - if (!m || (strcmp(m, "call") != 0 && strcmp(m, "jmp") != 0 && strcmp(m, "je") != 0 && - strcmp(m, "jne") != 0 && strcmp(m, "jz") != 0 && strcmp(m, "jnz") != 0)) { + if (!m || + (strcasecmp(m, "call") != 0 && strcasecmp(m, "jmp") != 0 && strcasecmp(m, "je") != 0 && + strcasecmp(m, "jne") != 0 && strcasecmp(m, "jz") != 0 && strcasecmp(m, "jnz") != 0)) { return NULL; } TSNode ops = ts_node_child_by_field_name(node, TS_FIELD("operands")); @@ -941,20 +981,6 @@ static char *extract_nix_callee(CBMArena *a, TSNode node, const char *source, co return NULL; } -// Agda: function application `f x y` parses as an `expr` whose named children are -// `atom`s (no dedicated application node). Treat an `expr` with >= 2 atom children -// as a call whose callee is the head atom's identifier. -static char *extract_agda_callee(CBMArena *a, TSNode node, const char *source, const char *nk) { - if (strcmp(nk, "expr") != 0 || ts_node_named_child_count(node) < 2) { - return NULL; - } - TSNode head = ts_node_named_child(node, 0); - if (strcmp(ts_node_type(head), "atom") != 0) { - return NULL; - } - return first_leaf_identifier(a, head, source); -} - // Make: `$(shell ...)` is a `shell_function` node; the callee is the literal // `shell` keyword. tree-sitter-make also exposes `function_call` for other // builtins ($(wildcard ...), $(patsubst ...)). @@ -1041,6 +1067,14 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char * char *c = extract_meson_callee(a, node, source, nk); return c ? c : extract_scripting_callee(a, node, source, lang, nk); } + if (lang == CBM_LANG_MAKEFILE) { + char *c = extract_make_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } + if (lang == CBM_LANG_PUPPET) { + char *c = extract_puppet_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } if (lang == CBM_LANG_SCSS) { char *c = extract_scss_callee(a, node, source, nk); @@ -1054,12 +1088,24 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char * char *c = extract_sql_callee(a, node, source, nk); return c ? c : extract_scripting_callee(a, node, source, lang, nk); } + if (lang == CBM_LANG_ELM) { + char *c = extract_elm_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } + if (lang == CBM_LANG_NIX) { + char *c = extract_nix_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } if (lang == CBM_LANG_COBOL) { char *c = extract_cobol_callee(a, node, source, nk); return c ? c : extract_scripting_callee(a, node, source, lang, nk); } - if (lang == CBM_LANG_ELM) { - char *c = extract_elm_callee(a, node, source, nk); + if (lang == CBM_LANG_VHDL) { + char *c = extract_vhdl_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } + if (lang == CBM_LANG_VERILOG || lang == CBM_LANG_SYSTEMVERILOG) { + char *c = extract_hdl_callee(a, node, source, nk); return c ? c : extract_scripting_callee(a, node, source, lang, nk); } @@ -1088,6 +1134,14 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char * if (lang == CBM_LANG_DART) { return extract_dart_callee(a, node, source, nk); } + if (lang == CBM_LANG_AGDA) { + char *c = extract_agda_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } + if (lang == CBM_LANG_NASM) { + char *c = extract_nasm_callee(a, node, source, nk); + return c ? c : extract_scripting_callee(a, node, source, lang, nk); + } if (lang == CBM_LANG_OBJC) { return extract_objc_callee(a, node, source, nk); } @@ -1103,24 +1157,6 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char * if (lang == CBM_LANG_SWIFT) { return extract_swift_callee(a, node, source, nk); } - if (lang == CBM_LANG_VERILOG || lang == CBM_LANG_SYSTEMVERILOG) { - char *c = extract_hdl_callee(a, node, source, nk); - if (c) { - return c; - } - } - if (lang == CBM_LANG_VHDL) { - char *c = extract_vhdl_callee(a, node, source, nk); - if (c) { - return c; - } - } - if (lang == CBM_LANG_NASM) { - char *c = extract_nasm_callee(a, node, source, nk); - if (c) { - return c; - } - } if (lang == CBM_LANG_LLVM_IR) { char *c = extract_llvm_callee(a, node, source, nk); if (c) { @@ -1133,87 +1169,50 @@ static char *extract_callee_lang_specific(CBMArena *a, TSNode node, const char * return c; } } - if (lang == CBM_LANG_AGDA) { - char *c = extract_agda_callee(a, node, source, nk); - if (c) { - return c; - } - } - if (lang == CBM_LANG_NIX) { - char *c = extract_nix_callee(a, node, source, nk); - if (c) { - return c; - } - } - if (lang == CBM_LANG_MAKEFILE) { - char *c = extract_make_callee(a, node, source, nk); - if (c) { - return c; - } - } if (lang == CBM_LANG_JUST) { char *c = extract_just_callee(a, node, source, nk); if (c) { return c; } } - if (lang == CBM_LANG_PUPPET) { - char *c = extract_puppet_callee(a, node, source, nk); - if (c) { - return c; - } - } if (lang == CBM_LANG_OBJECTSCRIPT_UDL || lang == CBM_LANG_OBJECTSCRIPT_ROUTINE) { - // ##class(Pkg.Class).Method() -> "Pkg.Class.Method" if (strcmp(nk, "class_method_call") == 0) { TSNode class_ref = cbm_find_child_by_kind(node, "class_ref"); TSNode method_name = cbm_find_child_by_kind(node, "method_name"); - if (!ts_node_is_null(class_ref) && !ts_node_is_null(method_name)) { - TSNode cname = cbm_find_child_by_kind(class_ref, "class_name"); - if (ts_node_is_null(cname)) { - return NULL; - } - char *cls = cbm_node_text(a, cname, source); - if (!cls || !cls[0]) { - return NULL; - } - TSNode mname_ident = ts_node_named_child_count(method_name) > 0 - ? ts_node_named_child(method_name, 0) - : (TSNode){0}; - if (ts_node_is_null(mname_ident)) { - return cls; - } - char *meth = cbm_node_text(a, mname_ident, source); - if (!meth || !meth[0]) { - return cls; - } - return cbm_arena_sprintf(a, "%s.%s", cls, meth); + if (ts_node_is_null(class_ref) || ts_node_is_null(method_name)) { + return NULL; } - return NULL; + TSNode class_name = cbm_find_child_by_kind(class_ref, "class_name"); + if (ts_node_is_null(class_name)) { + return NULL; + } + char *class_text = cbm_node_text(a, class_name, source); + TSNode method_ident = ts_node_named_child_count(method_name) > 0 + ? ts_node_named_child(method_name, 0) + : (TSNode){0}; + if (!class_text || !class_text[0] || ts_node_is_null(method_ident)) { + return class_text; + } + char *method_text = cbm_node_text(a, method_ident, source); + return method_text && method_text[0] + ? cbm_arena_sprintf(a, "%s.%s", class_text, method_text) + : class_text; } - // $$label^routine extrinsic / routine tag call -> the line_ref text if (strcmp(nk, "routine_tag_call") == 0) { TSNode line_ref = cbm_find_child_by_kind(node, "line_ref"); - if (!ts_node_is_null(line_ref)) { - return cbm_node_text(a, line_ref, source); - } - return NULL; + return ts_node_is_null(line_ref) ? NULL : cbm_node_text(a, line_ref, source); } - // $$$Macro(...) -> raw "$$$Name" callee (expanded later in handle_calls) if (strcmp(nk, "macro") == 0) { char *raw = cbm_node_text(a, node, source); - if (!raw || raw[0] != '$' || raw[1] != '$' || raw[2] != '$') { + if (!raw || strncmp(raw, "$$$", 3) != 0) { return NULL; } - char *name_start = raw + 3; - char *paren = strchr(name_start, '('); + char *name = raw + 3; + char *paren = strchr(name, '('); if (paren) { *paren = '\0'; } - if (!name_start[0]) { - return NULL; - } - return cbm_arena_sprintf(a, "$$$%s", name_start); + return name[0] ? cbm_arena_sprintf(a, "$$$%s", name) : NULL; } return NULL; } @@ -1465,6 +1464,14 @@ static char *gotemplate_string_child(CBMArena *a, TSNode parent, const char *sou return (char *)v; } +static TSNode find_call_arguments_node(TSNode node) { + TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); + if (ts_node_is_null(args)) { + args = cbm_find_child_by_kind(node, "argument_list"); + } + return args; +} + // Resolve a Go-template / Helm call to the referenced named template: // {{ template "x" . }} -> template_action, name is a string child // {{ include "x" . }} -> function_call(include), name is first string arg @@ -1484,10 +1491,7 @@ static char *gotemplate_callee(CBMArena *a, TSNode node, const char *source) { strcmp(fname, "tpl") != 0)) { return NULL; } - TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); - if (ts_node_is_null(args)) { - args = cbm_find_child_by_kind(node, "argument_list"); - } + TSNode args = find_call_arguments_node(node); if (ts_node_is_null(args)) { return NULL; } @@ -1836,14 +1840,20 @@ static void extract_jsx_component_ref(CBMExtractCtx *ctx, TSNode node, const cha } } -// Kotlin: `a OP b` desugars to an operator-method call `a.(b)`. The -// generic call walk keys on call_expression nodes and so never sees these -// precedence-specific binary-expression nodes, leaving the type-aware LSP -// operator resolution (lsp_kt_operator -> the user `operator fun`) with no call -// site to attach to. Record a textual call to the operator method's bare name; -// the operator-token -> method mapping mirrors kotlin_lsp.c's binary handler so -// the names join. Builtin operands (Int+Int) resolve to a stdlib type with no -// graph node and drop, exactly as before — only user `operator fun`s gain edges. +/* Kotlin operator/convention syntax and Java/C++ implicit syntax can represent + * real calls without a call_expression node. These helpers add the textual + * CBMCall records required for existing type-aware LSP resolutions to join. */ +static void push_synthetic_call(CBMExtractCtx *ctx, TSNode node, const char *callee, + const char *enclosing_func_qn) { + if (!callee || !callee[0]) { + return; + } + CBMCall call = {0}; + call.callee_name = callee; + call.enclosing_func_qn = enclosing_func_qn; + call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; + cbm_calls_push(&ctx->result->calls, ctx->arena, call); +} static void extract_kotlin_operator_call(CBMExtractCtx *ctx, TSNode node, const char *kind, const char *enclosing_func_qn) { if (strcmp(kind, "binary_expression") != 0 && strcmp(kind, "additive_expression") != 0 && @@ -1873,15 +1883,16 @@ static void extract_kotlin_operator_call(CBMExtractCtx *ctx, TSNode node, const size_t blen = (size_t)(rhs_start - lhs_end); const char *op_method = NULL; if (cbm_memmem(between, blen, "===", 3) || cbm_memmem(between, blen, "!==", 3)) { - return; // identity comparison: no operator method - } else if (cbm_memmem(between, blen, "==", 2) || cbm_memmem(between, blen, "!=", 2)) { + return; + } + if (cbm_memmem(between, blen, "==", 2) || cbm_memmem(between, blen, "!=", 2)) { op_method = "equals"; } else if (cbm_memmem(between, blen, "..<", 3)) { op_method = "rangeUntil"; } else if (cbm_memmem(between, blen, "..", 2)) { op_method = "rangeTo"; } else if (cbm_memmem(between, blen, "<", 1) || cbm_memmem(between, blen, ">", 1)) { - op_method = "compareTo"; // covers <, >, <=, >= + op_method = "compareTo"; } else if (cbm_memmem(between, blen, "+", 1)) { op_method = "plus"; } else if (cbm_memmem(between, blen, "-", 1)) { @@ -1893,38 +1904,38 @@ static void extract_kotlin_operator_call(CBMExtractCtx *ctx, TSNode node, const } else if (cbm_memmem(between, blen, "%", 1)) { op_method = "rem"; } - if (!op_method) { - return; - } - CBMCall call = {0}; - call.callee_name = op_method; - call.enclosing_func_qn = enclosing_func_qn; - call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; - cbm_calls_push(&ctx->result->calls, ctx->arena, call); + push_synthetic_call(ctx, node, op_method, enclosing_func_qn); } -// Kotlin convention-desugared calls that the call walk never sees as -// call_expressions: `val (a,b) = e` -> e.component1()/e.component2(); and -// `for (x in e)` -> e.iterator()/hasNext()/next(). Record textual calls to those -// operator-convention method names so the LSP's lsp_kt_destructure / -// lsp_kt_iterator resolutions have a call site to join (names match the LSP's). -static void kt_push_implicit_call(CBMExtractCtx *ctx, TSNode node, const char *callee, - const char *enclosing_func_qn) { - CBMCall call = {0}; - call.callee_name = callee; - call.enclosing_func_qn = enclosing_func_qn; - call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; - cbm_calls_push(&ctx->result->calls, ctx->arena, call); +static void extract_kotlin_desugared_calls(CBMExtractCtx *ctx, TSNode node, const char *kind, + const char *enclosing_func_qn) { + if (strcmp(kind, "property_declaration") == 0) { + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(node, i); + if (strcmp(ts_node_type(c), "multi_variable_declaration") != 0) { + continue; + } + uint32_t vc = ts_node_named_child_count(c); + uint32_t comp = 0; + for (uint32_t j = 0; j < vc; j++) { + TSNode v = ts_node_named_child(c, j); + if (strcmp(ts_node_type(v), "variable_declaration") != 0) { + continue; + } + comp++; + push_synthetic_call(ctx, node, cbm_arena_sprintf(ctx->arena, "component%u", comp), + enclosing_func_qn); + } + break; + } + } else if (strcmp(kind, "for_statement") == 0) { + push_synthetic_call(ctx, node, "iterator", enclosing_func_qn); + push_synthetic_call(ctx, node, "hasNext", enclosing_func_qn); + push_synthetic_call(ctx, node, "next", enclosing_func_qn); + } } -// C++ overloaded binary operator `a + b`: the operator method (`operator+`) is -// invoked implicitly, so the call walk never sees a call node. Synthesize a -// textual call to the bare operator name so the c-LSP's lsp_operator resolution -// (which keys the same `operator` member on the lhs type) has a call site to -// join. The operator token is the first unnamed child, mirroring c_lsp.c's binary -// handling. Builtin-operand expressions (int + int) synthesize an `operator+` -// callee too, but no such member exists so the call resolves to nothing and is -// dropped — no spurious edge. static void extract_cpp_operator_call(CBMExtractCtx *ctx, TSNode node, const char *kind, const char *enclosing_func_qn) { if (strcmp(kind, "binary_expression") != 0) { @@ -1942,32 +1953,15 @@ static void extract_cpp_operator_call(CBMExtractCtx *ctx, TSNode node, const cha } char *op = cbm_node_text(ctx->arena, child, ctx->source); if (op && op[0]) { - CBMCall call = {0}; - call.callee_name = cbm_arena_sprintf(ctx->arena, "operator%s", op); - call.enclosing_func_qn = enclosing_func_qn; - call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; - cbm_calls_push(&ctx->result->calls, ctx->arena, call); + push_synthetic_call(ctx, node, cbm_arena_sprintf(ctx->arena, "operator%s", op), + enclosing_func_qn); } break; } } -// C++ implicit calls that produce no textual call node: the destructor -// (`delete p`), the copy/move constructor (`T a = b;` copy-init), and the -// conversion operator (`if (obj)` where obj has `operator bool`). The c-LSP -// resolves each to the corresponding member but there is no call site to join -// to (callable=0). Synthesize a textual call sourced to the enclosing function -// so the lsp_{destructor,copy_constructor,conversion} resolution binds. -// -// - destructor: the callee QN embeds the type (`T.~T`), which is not textually -// available from `delete p`, so it joins via the reason gate — c_lsp stashes -// the operand text in `reason` and the synthesized callee is that same text. -// - copy constructor: the callee short-name is the constructed type (`T`), -// which IS textually present as the declaration's type — join by short-name. -// - conversion: the callee short-name is the type-independent `operator bool`. -// -// Spurious synthesis (a condition/operand that has no such member) resolves to -// nothing and is dropped, so no extra edge is produced. +/* These implicit C++ calls have no call node; unresolved synthetic names are + * dropped later, while valid names join the corresponding LSP resolution. */ static void extract_cpp_implicit_calls(CBMExtractCtx *ctx, TSNode node, const char *kind, const char *enclosing_func_qn) { const char *callee = NULL; @@ -1981,8 +1975,6 @@ static void extract_cpp_implicit_calls(CBMExtractCtx *ctx, TSNode node, const ch } } else if (strcmp(kind, "if_statement") == 0 || strcmp(kind, "while_statement") == 0 || strcmp(kind, "do_statement") == 0) { - // `if (obj)` invokes obj's `operator bool`. Only a lone-identifier - // condition triggers it; comparisons/logical exprs evaluate to bool. TSNode cond = ts_node_child_by_field_name(node, TS_FIELD("condition")); if (!ts_node_is_null(cond)) { TSNode inner = cond; @@ -1995,7 +1987,6 @@ static void extract_cpp_implicit_calls(CBMExtractCtx *ctx, TSNode node, const ch } } } else if (strcmp(kind, "declaration") == 0) { - // `T a = b;` — copy-init from an identifier invokes T's copy constructor. TSNode type = ts_node_child_by_field_name(node, TS_FIELD("type")); TSNode decl = ts_node_child_by_field_name(node, TS_FIELD("declarator")); if (!ts_node_is_null(type) && !ts_node_is_null(decl) && @@ -2010,52 +2001,9 @@ static void extract_cpp_implicit_calls(CBMExtractCtx *ctx, TSNode node, const ch } } } - if (callee && callee[0]) { - CBMCall call = {0}; - call.callee_name = callee; - call.enclosing_func_qn = enclosing_func_qn; - call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; - cbm_calls_push(&ctx->result->calls, ctx->arena, call); - } + push_synthetic_call(ctx, node, callee, enclosing_func_qn); } -static void extract_kotlin_desugared_calls(CBMExtractCtx *ctx, TSNode node, const char *kind, - const char *enclosing_func_qn) { - if (strcmp(kind, "property_declaration") == 0) { - uint32_t nc = ts_node_named_child_count(node); - for (uint32_t i = 0; i < nc; i++) { - TSNode c = ts_node_named_child(node, i); - if (strcmp(ts_node_type(c), "multi_variable_declaration") != 0) { - continue; - } - // One componentN() call per destructured variable. - uint32_t vc = ts_node_named_child_count(c); - uint32_t comp = 0; - for (uint32_t j = 0; j < vc; j++) { - TSNode v = ts_node_named_child(c, j); - if (strcmp(ts_node_type(v), "variable_declaration") != 0) { - continue; - } - comp++; - kt_push_implicit_call(ctx, node, cbm_arena_sprintf(ctx->arena, "component%u", comp), - enclosing_func_qn); - } - break; - } - } else if (strcmp(kind, "for_statement") == 0) { - kt_push_implicit_call(ctx, node, "iterator", enclosing_func_qn); - kt_push_implicit_call(ctx, node, "hasNext", enclosing_func_qn); - kt_push_implicit_call(ctx, node, "next", enclosing_func_qn); - } -} - -// Java method reference `Lhs::name` (e.g. `String::length`, `Foo::new`). The -// call walk only visits call_expression-like nodes, so a method_reference never -// becomes a call and the LSP's lsp_method_ref resolution has no call site to -// attach to. Record a textual call to the referenced method's bare name (the -// constructor ref `Lhs::new` uses the unnamed `new` token); the LSP join then -// matches on the bare name. The referenced method IS invoked indirectly, so -// this is an accurate call edge (mirrors java_lsp.c resolve_method_reference). static void extract_java_method_reference(CBMExtractCtx *ctx, TSNode node, const char *kind, const char *enclosing_func_qn) { if (strcmp(kind, "method_reference") != 0) { @@ -2070,13 +2018,9 @@ static void extract_java_method_reference(CBMExtractCtx *ctx, TSNode node, const mname = cbm_node_text(ctx->arena, ts_node_named_child(node, nc - 1), ctx->source); } if (!mname || !mname[0]) { - mname = "new"; // constructor reference `Lhs::new` — `new` is unnamed + mname = "new"; } - CBMCall call = {0}; - call.callee_name = mname; - call.enclosing_func_qn = enclosing_func_qn; - call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; - cbm_calls_push(&ctx->result->calls, ctx->arena, call); + push_synthetic_call(ctx, node, mname, enclosing_func_qn); } // ObjectScript: resolve `var.Method(...)` / `..Property.Method(...)` instance @@ -2213,6 +2157,7 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk call.loop_depth = state->loop_depth; // enclosing loop nesting at this call call.branch_depth = state->branch_depth; // enclosing branch nesting at this call call.start_line = (int)ts_node_start_point(node).row + TS_LINE_OFFSET; + call.is_macro_invocation = strcmp(ts_node_type(node), "macro_invocation") == 0; // Perl-only: flag arrow/method calls ($obj->m / Class->m). The // generic short-name resolver cannot place a method without a known // receiver type, so the call-resolution pass suppresses those edges. @@ -2245,9 +2190,9 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk } } - TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); - // ObjectScript stores args under oref_method/method_args, not the - // generic "arguments" field. + TSNode args = find_call_arguments_node(node); + /* ObjectScript stores arguments under oref_method/method_args, + * not the generic arguments field used by most grammars. */ if (ts_node_is_null(args) && (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL || ctx->language == CBM_LANG_OBJECTSCRIPT_ROUTINE)) { TSNode oref = cbm_find_child_by_kind(node, "oref_method"); diff --git a/internal/cbm/extract_channels.c b/internal/cbm/extract_channels.c index c12d4ebeb..4905a7042 100644 --- a/internal/cbm/extract_channels.c +++ b/internal/cbm/extract_channels.c @@ -26,24 +26,27 @@ #include "foundation/constants.h" #include "extract_node_stack.h" #include "tree_sitter/api.h" +#include #include +#include #include enum { - CHAN_CONST_CAP = 256, /* max tracked identifiers per file */ - CHAN_IDENT_MAX = 128, /* max identifier length tracked */ - CHAN_STACK_CAP = 4096, /* traversal stack depth per walk */ - CHAN_DIR_UNKNOWN = -1, /* unrecognized method → no channel */ + CHAN_CONST_INITIAL_CAPACITY = CBM_SZ_32, + CHAN_STACK_CAP = CBM_SZ_4K, /* initial traversal stack capacity */ + CHAN_DIR_UNKNOWN = -1, /* unrecognized method → no channel */ }; typedef struct { const char *name; /* borrowed — points into arena */ const char *value; /* borrowed — points into arena */ + int source_order; } chan_const_t; typedef struct { - chan_const_t items[CHAN_CONST_CAP]; + chan_const_t *items; int count; + int capacity; } chan_const_table_t; /* ── String literal helpers ──────────────────────────────────────── */ @@ -94,17 +97,77 @@ static const char *literal_from_first_child(CBMExtractCtx *ctx, TSNode node) { /* ── Constant resolution table ──────────────────────────────────── */ +static bool chan_const_table_append(chan_const_table_t *tbl, const char *name, const char *value) { + if (!tbl || !name || !value) { + return true; + } + if (tbl->count >= tbl->capacity) { + int next_capacity = CHAN_CONST_INITIAL_CAPACITY; + if (tbl->capacity > 0) { + if (tbl->capacity > INT_MAX / CBM_SZ_2) { + return false; + } + next_capacity = tbl->capacity * CBM_SZ_2; + } + if ((size_t)next_capacity > SIZE_MAX / sizeof(*tbl->items)) { + return false; + } + chan_const_t *grown = realloc(tbl->items, (size_t)next_capacity * sizeof(*tbl->items)); + if (!grown) { + return false; + } + tbl->items = grown; + tbl->capacity = next_capacity; + } + tbl->items[tbl->count] = + (chan_const_t){.name = name, .value = value, .source_order = tbl->count}; + tbl->count++; + return true; +} + +static int chan_const_compare(const void *lhs, const void *rhs) { + const chan_const_t *a = lhs; + const chan_const_t *b = rhs; + int name_cmp = strcmp(a->name, b->name); + if (name_cmp != 0) { + return name_cmp; + } + return (a->source_order > b->source_order) - (a->source_order < b->source_order); +} + +static void chan_const_table_sort(chan_const_table_t *tbl) { + if (tbl && tbl->count > 1) { + qsort(tbl->items, (size_t)tbl->count, sizeof(*tbl->items), chan_const_compare); + } +} + +static void chan_const_table_destroy(chan_const_table_t *tbl) { + if (!tbl) { + return; + } + free(tbl->items); + *tbl = (chan_const_table_t){0}; +} + +static void chan_const_table_report_allocation_failure(CBMExtractCtx *ctx) { + ctx->result->has_error = true; + ctx->result->error_msg = + cbm_arena_strdup(ctx->arena, "channel constant table allocation failed"); +} + /* Walk the whole tree once and collect `const IDENT = "value"` bindings so * later passes can resolve bare-identifier channel arguments. Only scalar * string literals are tracked — template literals and expressions are left * unresolved. This is a flat lookup; scope boundaries are ignored (a single - * const table per file is sufficient for the common Socket.IO pattern). */ -static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) { + * const table per file is sufficient for the common Socket.IO pattern). + * Returns false on allocation failure rather than publishing a silently + * truncated identifier table. */ +static bool scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) { TSNodeStack stack; ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); ts_nstack_push(&stack, ctx->arena, ctx->root); - while (stack.count > 0 && tbl->count < CHAN_CONST_CAP) { + while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *kind = ts_node_type(node); @@ -119,10 +182,8 @@ static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) { char *name_text = cbm_node_text(ctx->arena, name_node, ctx->source); char *value_text = cbm_node_text(ctx->arena, value_node, ctx->source); const char *unq = unquote_string(ctx->arena, value_text); - if (name_text && unq) { - tbl->items[tbl->count].name = name_text; - tbl->items[tbl->count].value = unq; - tbl->count++; + if (name_text && unq && !chan_const_table_append(tbl, name_text, unq)) { + return false; } } } @@ -130,15 +191,17 @@ static void scan_string_consts_js(CBMExtractCtx *ctx, chan_const_table_t *tbl) { ts_nstack_push_children(&stack, ctx->arena, node); } + chan_const_table_sort(tbl); + return true; } /* Python constant resolution: NAME = "value" (assignment node). */ -static void scan_string_consts_python(CBMExtractCtx *ctx, chan_const_table_t *tbl) { +static bool scan_string_consts_python(CBMExtractCtx *ctx, chan_const_table_t *tbl) { TSNodeStack stack; ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); ts_nstack_push(&stack, ctx->arena, ctx->root); - while (stack.count > 0 && tbl->count < CHAN_CONST_CAP) { + while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *kind = ts_node_type(node); @@ -153,10 +216,8 @@ static void scan_string_consts_python(CBMExtractCtx *ctx, chan_const_table_t *tb if (!val) { val = literal_from_first_child(ctx, right); } - if (name && val) { - tbl->items[tbl->count].name = name; - tbl->items[tbl->count].value = val; - tbl->count++; + if (name && val && !chan_const_table_append(tbl, name, val)) { + return false; } } } @@ -166,19 +227,27 @@ static void scan_string_consts_python(CBMExtractCtx *ctx, chan_const_table_t *tb ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); } } + chan_const_table_sort(tbl); + return true; } -/* Resolve an identifier against the constant table. Returns NULL on miss. */ +/* Resolve an identifier against the sorted constant table. Duplicate names + * preserve the first source-order binding used by the historical linear scan. */ static const char *resolve_identifier(const chan_const_table_t *tbl, const char *name) { - if (!name) { + if (!tbl || !name) { return NULL; } - for (int i = 0; i < tbl->count; i++) { - if (tbl->items[i].name && strcmp(tbl->items[i].name, name) == 0) { - return tbl->items[i].value; + int lo = 0; + int hi = tbl->count; + while (lo < hi) { + int mid = lo + (hi - lo) / CBM_SZ_2; + if (strcmp(tbl->items[mid].name, name) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; } } - return NULL; + return lo < tbl->count && strcmp(tbl->items[lo].name, name) == 0 ? tbl->items[lo].value : NULL; } /* ── Enclosing function detection ───────────────────────────────── */ @@ -369,7 +438,11 @@ static void js_process_call(CBMExtractCtx *ctx, TSNode call, const chan_const_ta static void extract_channels_js(CBMExtractCtx *ctx) { chan_const_table_t consts = {0}; - scan_string_consts_js(ctx, &consts); + if (!scan_string_consts_js(ctx, &consts)) { + chan_const_table_destroy(&consts); + chan_const_table_report_allocation_failure(ctx); + return; + } /* Second pass: walk the tree looking for call_expression nodes. */ TSNodeStack stack; @@ -383,6 +456,7 @@ static void extract_channels_js(CBMExtractCtx *ctx) { } ts_nstack_push_children(&stack, ctx->arena, node); } + chan_const_table_destroy(&consts); } /* ══════════════════════════════════════════════════════════════════ @@ -546,7 +620,11 @@ static void py_process_decorator(CBMExtractCtx *ctx, TSNode decorator, static void extract_channels_python(CBMExtractCtx *ctx) { chan_const_table_t consts = {0}; - scan_string_consts_python(ctx, &consts); + if (!scan_string_consts_python(ctx, &consts)) { + chan_const_table_destroy(&consts); + chan_const_table_report_allocation_failure(ctx); + return; + } TSNodeStack stack; ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); @@ -565,6 +643,7 @@ static void extract_channels_python(CBMExtractCtx *ctx) { ts_nstack_push(&stack, ctx->arena, ts_node_child(node, (uint32_t)i)); } } + chan_const_table_destroy(&consts); } /* ══════════════════════════════════════════════════════════════════ diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 06057e28b..ead7d1510 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -15,14 +15,10 @@ #include #include -// Buffer sizes for local arrays (base classes, params, return types). +// Buffer sizes for local arrays (params and return types). #define MAX_COMMENT_LEN 500 -#define MAX_BASES 16 -#define MAX_BASES_MINUS_1 15 #define MAX_PARAMS CBM_SZ_32 #define MAX_PARAMS_MINUS_1 31 -#define MAX_RETURN_TYPES 16 -#define MAX_RETURN_TYPES_MINUS_1 15 // Tree traversal limits. enum { @@ -31,10 +27,6 @@ enum { EXPORT_ANCESTOR_DEPTH = 4, FUNC_PARENT_CLIMB_LIMIT = 4, /* fun_expr -> term -> uni_term -> let_binding (Nickel) */ - /* Nix header lambdas to descend before the file's body: `{ pkgs, ... }:` is one, - * the nixpkgs overlay `final: prev:` is two. Bounded so a pathological chain - * cannot spin. */ - NIX_HEADER_HOP_MAX = 8, DECORATOR_SCAN_LIMIT = 3, C_RETURN_WALK_DEPTH = 5, VAR_RECURSION_LIMIT = 8, @@ -1558,16 +1550,26 @@ static bool try_route_from_decorator_call(CBMArena *a, TSNode dchild, const char if (!method) { return false; } + const char *dot = fn_text ? strrchr(fn_text, '.') : NULL; + bool has_receiver = dot && dot[SKIP_CHAR] != '\0'; + bool is_generic_route = fn_text && (strcmp(dot ? dot + SKIP_CHAR : fn_text, "route") == 0 || + strcmp(dot ? dot + SKIP_CHAR : fn_text, "api_route") == 0); TSNode args = find_decorator_args(dchild); if (!ts_node_is_null(args)) { const char *path = extract_route_path_from_args(a, args, source); if (path) { + if (!has_receiver && !is_generic_route) { + return false; + } *out_path = path; *out_method = method; return true; } } + if (!has_receiver) { + return false; + } *out_path = "/"; *out_method = method; return true; @@ -2012,30 +2014,161 @@ static bool rust_def_is_test(const char *const *decorators) { return false; } -static const char *rust_cfg_qualified_name(CBMArena *a, const char *base_qn, - const char *const *decorators) { - if (!decorators) { +typedef struct { + const char *start; + const char *end; +} rust_cfg_span_t; + +/* Accept only a direct #[cfg(...)] attribute. cfg_attr may contain a nested + * cfg token, but it has different conditional semantics and must not be + * mistaken for an unconditional identity predicate. */ +static bool rust_direct_cfg_span(TSNode attr, const char *source, rust_cfg_span_t *span) { + uint32_t start = ts_node_start_byte(attr); + uint32_t end = ts_node_end_byte(attr); + if (!source || !span || end <= start) { + return false; + } + const char *p = source + start; + const char *attr_end = source + end; + while (p < attr_end && isspace((unsigned char)*p)) { + p++; + } + if (p >= attr_end || *p++ != '#') { + return false; + } + while (p < attr_end && isspace((unsigned char)*p)) { + p++; + } + if (p >= attr_end || *p++ != '[') { + return false; + } + while (p < attr_end && isspace((unsigned char)*p)) { + p++; + } + const char *cfg = p; + const size_t cfg_name_len = sizeof("cfg") - SKIP_ONE; + /* Tree-sitter's attribute span extends through the closing bracket. + * cppcheck 2.20 loses that relation after the whitespace loop and + * incorrectly treats every remaining span as shorter than "cfg". */ + // cppcheck-suppress knownConditionTrueFalse + if ((size_t)(attr_end - p) < cfg_name_len) { + return false; + } + if (memcmp(p, "cfg", cfg_name_len) != 0) { + return false; + } + p += cfg_name_len; + while (p < attr_end && isspace((unsigned char)*p)) { + p++; + } + if (p >= attr_end || *p != '(') { + return false; + } + const char *cfg_end = attr_end; + while (cfg_end > p && cfg_end[-SKIP_ONE] != ')') { + cfg_end--; + } + if (cfg_end <= p) { + return false; + } + span->start = cfg; + span->end = cfg_end; + return true; +} + +/* Copy a stable predicate spelling, removing only insignificant whitespace + * outside quoted values. Keeping quotes and in-string spaces prevents + * feature="a b" from colliding with feature="ab". */ +static size_t rust_compact_cfg_span(char *dst, rust_cfg_span_t span) { + size_t out = 0; + char quote = '\0'; + bool escaped = false; + for (const char *p = span.start; p < span.end; p++) { + bool keep = quote || !isspace((unsigned char)*p); + if (keep && dst) { + dst[out] = *p; + } + if (keep) { + out++; + } + if (quote) { + if (escaped) { + escaped = false; + } else if (*p == '\\') { + escaped = true; + } else if (*p == quote) { + quote = '\0'; + } + } else if (*p == '"' || *p == '\'') { + quote = *p; + } + } + return out; +} + +const char *cbm_rust_cfg_qualified_name(CBMArena *a, TSNode node, const char *source, + const char *base_qn) { + if (!a || !source || !base_qn) { return base_qn; } - for (int i = 0; decorators[i]; i++) { - const char *cfg = strstr(decorators[i], "cfg("); - if (!cfg) { + const CBMLangSpec *spec = cbm_lang_spec(CBM_LANG_RUST); + TSNode first = node; + TSNode prev = ts_node_prev_sibling(node); + while (!ts_node_is_null(prev)) { + if (!cbm_kind_in_set(prev, spec->decorator_node_types)) { + if (ts_node_is_named(prev)) { + break; + } + prev = ts_node_prev_sibling(prev); continue; } - /* Build a compact predicate suffix from the cfg(...) text, dropping - * whitespace and quotes so the QN stays readable and stable. */ - char buf[CBM_SZ_256]; - size_t bi = 0; - for (const char *p = cfg; *p && bi + 1 < sizeof(buf); p++) { - if (*p == ' ' || *p == '\t' || *p == '"' || *p == '\'') { - continue; - } - buf[bi++] = *p; + first = prev; + prev = ts_node_prev_sibling(prev); + } + + size_t base_len = strlen(base_qn); + size_t result_len = base_len; + bool found = false; + for (TSNode attr = first; !ts_node_is_null(attr) && !ts_node_eq(attr, node); + attr = ts_node_next_sibling(attr)) { + if (!cbm_kind_in_set(attr, spec->decorator_node_types)) { + continue; + } + rust_cfg_span_t span; + if (!rust_direct_cfg_span(attr, source, &span)) { + continue; } - buf[bi] = '\0'; - return cbm_arena_sprintf(a, "%s#%s", base_qn, buf); + size_t compact_len = rust_compact_cfg_span(NULL, span); + if (result_len >= SIZE_MAX || compact_len > SIZE_MAX - result_len - SKIP_ONE) { + return base_qn; + } + result_len += SKIP_ONE + compact_len; + found = true; + } + if (!found || result_len == SIZE_MAX) { + return base_qn; + } + + char *result = cbm_arena_alloc(a, result_len + SKIP_ONE); + if (!result) { + return base_qn; } - return base_qn; + memcpy(result, base_qn, base_len); + size_t out = base_len; + for (TSNode attr = first; !ts_node_is_null(attr) && !ts_node_eq(attr, node); + attr = ts_node_next_sibling(attr)) { + if (!cbm_kind_in_set(attr, spec->decorator_node_types)) { + continue; + } + rust_cfg_span_t span; + if (!rust_direct_cfg_span(attr, source, &span)) { + continue; + } + result[out++] = '#'; + out += rust_compact_cfg_span(result + out, span); + } + result[out] = '\0'; + return result; } // Extract base class name text from a single base_class child node. @@ -2067,29 +2200,99 @@ static char *extract_cpp_base_text(CBMArena *a, TSNode bc, const char *source) { return NULL; } +typedef struct { + const char *inline_items[CBM_SZ_16]; + const char **items; + size_t count; + size_t capacity; + bool failed; +} base_class_list_t; + +/* + * Keep the inherited 16-pointer stack fast path, but use it as an optimization + * rather than a semantic cap. Larger lists grow geometrically in the result + * arena. Runtime is amortized O(1) per append and O(B) overall; temporary + * pointer blocks remain O(B) until the file result is freed. + */ +static bool base_class_list_reserve(CBMArena *a, base_class_list_t *list, size_t additional) { + if (list->failed) { + return false; + } + if (!list->items) { + list->items = list->inline_items; + list->capacity = CBM_SZ_16; + } + if (additional > SIZE_MAX - list->count) { + list->failed = true; + return false; + } + size_t needed = list->count + additional; + if (needed <= list->capacity) { + return true; + } + size_t next = list->capacity ? list->capacity : CBM_SZ_8; + while (next < needed) { + if (next > SIZE_MAX / PAIR_LEN) { + list->failed = true; + return false; + } + next *= PAIR_LEN; + } + if (next > SIZE_MAX / sizeof(*list->items)) { + list->failed = true; + return false; + } + const char **grown = cbm_arena_alloc(a, next * sizeof(*grown)); + if (!grown) { + list->failed = true; + return false; + } + if (list->items && list->count > 0) { + memcpy(grown, list->items, list->count * sizeof(*grown)); + } + list->items = grown; + list->capacity = next; + return true; +} + +static bool base_class_list_push(CBMArena *a, base_class_list_t *list, const char *text) { + if (!text || !text[0]) { + return true; + } + if (!base_class_list_reserve(a, list, SKIP_ONE)) { + return false; + } + list->items[list->count++] = text; + return true; +} + +static const char **base_class_list_finish(CBMArena *a, base_class_list_t *list) { + if (list->failed || list->count == 0) { + return NULL; + } + if (list->count > SIZE_MAX / sizeof(*list->items) - NULL_TERM) { + return NULL; + } + const char **result = cbm_arena_alloc(a, (list->count + NULL_TERM) * sizeof(*result)); + if (!result) { + return NULL; + } + memcpy(result, list->items, list->count * sizeof(*result)); + result[list->count] = NULL; + return result; +} + // Extract base classes from a C++ base_class_clause node. static const char **extract_cpp_base_classes(CBMArena *a, TSNode clause, const char *source) { - const char *bases[MAX_BASES]; - int base_count = 0; + base_class_list_t bases = {0}; uint32_t bnc = ts_node_named_child_count(clause); - for (uint32_t bi = 0; bi < bnc && base_count < MAX_BASES_MINUS_1; bi++) { + for (uint32_t bi = 0; bi < bnc; bi++) { char *text = extract_cpp_base_text(a, ts_node_named_child(clause, bi), source); - if (text && text[0]) { - bases[base_count++] = text; - } - } - if (base_count > 0) { - const char **result = - (const char **)cbm_arena_alloc(a, (base_count + NULL_TERM) * sizeof(const char *)); - if (result) { - for (int j = 0; j < base_count; j++) { - result[j] = bases[j]; - } - result[base_count] = NULL; - return result; + if (!base_class_list_push(a, &bases, text)) { + return NULL; } } - return NULL; + return base_class_list_finish(a, &bases); } // Build a single-element NULL-terminated base class array. @@ -2149,29 +2352,16 @@ static const char *extract_csharp_base_child_text(CBMArena *a, TSNode bc, const /* Collect bases from a single base_list node into an arena-allocated array. */ static const char **collect_csharp_bases(CBMArena *a, TSNode base_list, const char *source) { - const char *bases[MAX_BASES]; - int base_count = 0; + base_class_list_t bases = {0}; uint32_t bnc = ts_node_named_child_count(base_list); - for (uint32_t bi = 0; bi < bnc && base_count < MAX_BASES_MINUS_1; bi++) { + for (uint32_t bi = 0; bi < bnc; bi++) { const char *text = extract_csharp_base_child_text(a, ts_node_named_child(base_list, bi), source); - if (text) { - bases[base_count++] = text; + if (!base_class_list_push(a, &bases, text)) { + return NULL; } } - if (base_count == 0) { - return NULL; - } - const char **result = - (const char **)cbm_arena_alloc(a, (base_count + NULL_TERM) * sizeof(const char *)); - if (!result) { - return NULL; - } - for (int j = 0; j < base_count; j++) { - result[j] = bases[j]; - } - result[base_count] = NULL; - return result; + return base_class_list_finish(a, &bases); } /* C# base_list: iterate children, find base_list node, extract bases. */ @@ -2190,15 +2380,11 @@ static const char **extract_csharp_base_list(CBMArena *a, TSNode node, const cha return NULL; } -// Append a base name (generic args stripped) to out[] if non-empty. -static void push_base_text(CBMArena *a, TSNode n, const char *source, const char **out, int out_cap, - int *count) { - if (*count >= out_cap) { - return; - } +// Append a base name (generic args stripped) if non-empty. +static bool push_base_text(CBMArena *a, TSNode n, const char *source, base_class_list_t *out) { char *t = cbm_node_text(a, n, source); if (!t) { - return; + return true; } char *angle = strchr(t, '<'); if (angle) { @@ -2210,29 +2396,27 @@ static void push_base_text(CBMArena *a, TSNode n, const char *source, const char if (last_bs) { t = last_bs + 1; } - if (t[0]) { - out[(*count)++] = t; - } + return base_class_list_push(a, out, t); } /* TypeScript/TSX: bases live in a `class_heritage` (class) or directly in an * `extends_type_clause` (interface). The extractor previously captured the * literal "extends"/"implements" keyword text instead of the type names. */ -static int collect_ts_bases(CBMArena *a, TSNode clause, const char *source, const char **out, - int out_cap, int *count) { +static bool collect_ts_bases(CBMArena *a, TSNode clause, const char *source, + base_class_list_t *out) { const char *kk = ts_node_type(clause); if (strcmp(kk, "extends_clause") == 0) { /* `extends_clause` carries the superclass in its `value` field. */ TSNode v = ts_node_child_by_field_name(clause, TS_FIELD("value")); if (!ts_node_is_null(v)) { - push_base_text(a, v, source, out, out_cap, count); + return push_base_text(a, v, source, out); } - return *count; + return true; } if (strcmp(kk, "implements_clause") == 0 || strcmp(kk, "extends_type_clause") == 0) { /* Named children are the implemented/extended types (possibly generic). */ uint32_t nc = ts_node_named_child_count(clause); - for (uint32_t i = 0; i < nc && *count < out_cap; i++) { + for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_named_child(clause, i); const char *ck = ts_node_type(c); if (strcmp(ck, "type_arguments") == 0) { @@ -2241,21 +2425,24 @@ static int collect_ts_bases(CBMArena *a, TSNode clause, const char *source, cons if (strcmp(ck, "generic_type") == 0) { TSNode nm = ts_node_child_by_field_name(c, TS_FIELD("name")); if (!ts_node_is_null(nm)) { - push_base_text(a, nm, source, out, out_cap, count); + if (!push_base_text(a, nm, source, out)) { + return false; + } continue; } } - push_base_text(a, c, source, out, out_cap, count); + if (!push_base_text(a, c, source, out)) { + return false; + } } } - return *count; + return true; } /* TypeScript: walk the class_heritage container (which holds extends_clause + * implements_clause), or handle a bare interface extends_type_clause. */ static const char **extract_ts_bases(CBMArena *a, TSNode node, const char *source) { - const char *bases[MAX_BASES]; - int count = 0; + base_class_list_t bases = {0}; uint32_t nc = ts_node_child_count(node); for (uint32_t i = 0; i < nc; i++) { TSNode child = ts_node_child(node, i); @@ -2263,33 +2450,23 @@ static const char **extract_ts_bases(CBMArena *a, TSNode node, const char *sourc if (strcmp(ck, "class_heritage") == 0) { uint32_t hc = ts_node_child_count(child); for (uint32_t j = 0; j < hc; j++) { - collect_ts_bases(a, ts_node_child(child, j), source, bases, MAX_BASES_MINUS_1, - &count); + if (!collect_ts_bases(a, ts_node_child(child, j), source, &bases)) { + return NULL; + } } } else if (strcmp(ck, "extends_type_clause") == 0) { - collect_ts_bases(a, child, source, bases, MAX_BASES_MINUS_1, &count); + if (!collect_ts_bases(a, child, source, &bases)) { + return NULL; + } } } - if (count == 0) { - return NULL; - } - const char **result = - (const char **)cbm_arena_alloc(a, (size_t)(count + NULL_TERM) * sizeof(const char *)); - if (!result) { - return NULL; - } - for (int i = 0; i < count; i++) { - result[i] = bases[i]; - } - result[count] = NULL; - return result; + return base_class_list_finish(a, &bases); } /* PHP: bases live in `base_clause` (extends) and `class_interface_clause` * (implements) child nodes; named children are `name`/`qualified_name`. */ static const char **extract_php_bases(CBMArena *a, TSNode node, const char *source) { - const char *bases[MAX_BASES]; - int count = 0; + base_class_list_t bases = {0}; uint32_t nc = ts_node_child_count(node); for (uint32_t i = 0; i < nc; i++) { TSNode child = ts_node_child(node, i); @@ -2298,74 +2475,72 @@ static const char **extract_php_bases(CBMArena *a, TSNode node, const char *sour continue; } uint32_t cc = ts_node_named_child_count(child); - for (uint32_t j = 0; j < cc && count < MAX_BASES_MINUS_1; j++) { - push_base_text(a, ts_node_named_child(child, j), source, bases, MAX_BASES_MINUS_1, - &count); + for (uint32_t j = 0; j < cc; j++) { + if (!push_base_text(a, ts_node_named_child(child, j), source, &bases)) { + return NULL; + } } } - if (count == 0) { - return NULL; + return base_class_list_finish(a, &bases); +} + +/* Kotlin: a grammar revision may expose one `delegation_specifier` directly or + * wrap all of them in `delegation_specifiers`. Each leaf holds either a bare + * `user_type` (interface) or a `constructor_invocation` (superclass). */ +static bool collect_kotlin_delegation_specifier(CBMArena *a, TSNode node, const char *source, + base_class_list_t *bases) { + if (strcmp(ts_node_type(node), "delegation_specifier") != 0) { + return true; } - const char **result = - (const char **)cbm_arena_alloc(a, (size_t)(count + NULL_TERM) * sizeof(const char *)); - if (!result) { - return NULL; + TSNode ut = ts_node_named_child(node, 0); + if (!ts_node_is_null(ut) && strcmp(ts_node_type(ut), "constructor_invocation") == 0) { + ut = ts_node_named_child(ut, 0); } - for (int i = 0; i < count; i++) { - result[i] = bases[i]; + if (ts_node_is_null(ut)) { + return true; } - result[count] = NULL; - return result; + TSNode ti = ut; + if (strcmp(ts_node_type(ut), "user_type") == 0 && ts_node_named_child_count(ut) > 0) { + ti = ts_node_named_child(ut, 0); + } + return push_base_text(a, ti, source, bases); } -/* Kotlin: supertypes live in `delegation_specifier` children. Each holds - * either a bare `user_type` (interface) or a `constructor_invocation` whose - * `user_type` is the superclass. Descend to the `type_identifier`. */ -static const char **extract_kotlin_bases(CBMArena *a, TSNode node, const char *source) { - const char *bases[MAX_BASES]; - int count = 0; - uint32_t nc = ts_node_child_count(node); - for (uint32_t i = 0; i < nc && count < MAX_BASES_MINUS_1; i++) { - TSNode child = ts_node_child(node, i); - if (strcmp(ts_node_type(child), "delegation_specifier") != 0) { - continue; - } - /* Find the user_type (directly or under a constructor_invocation). */ - TSNode ut = ts_node_named_child(child, 0); - if (!ts_node_is_null(ut) && strcmp(ts_node_type(ut), "constructor_invocation") == 0) { - ut = ts_node_named_child(ut, 0); - } - if (ts_node_is_null(ut)) { - continue; - } - /* user_type → type_identifier (first child); strip generic args. */ - TSNode ti = ut; - if (strcmp(ts_node_type(ut), "user_type") == 0 && ts_node_named_child_count(ut) > 0) { - ti = ts_node_named_child(ut, 0); - } - push_base_text(a, ti, source, bases, MAX_BASES_MINUS_1, &count); +static bool collect_kotlin_delegations(CBMArena *a, TSNode node, const char *source, + base_class_list_t *bases) { + const char *kind = ts_node_type(node); + if (strcmp(kind, "delegation_specifier") == 0) { + return collect_kotlin_delegation_specifier(a, node, source, bases); } - if (count == 0) { - return NULL; + if (strcmp(kind, "delegation_specifiers") != 0) { + return true; } - const char **result = - (const char **)cbm_arena_alloc(a, (size_t)(count + NULL_TERM) * sizeof(const char *)); - if (!result) { - return NULL; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + if (!collect_kotlin_delegation_specifier(a, ts_node_named_child(node, i), source, bases)) { + return false; + } } - for (int i = 0; i < count; i++) { - result[i] = bases[i]; + return true; +} + +static const char **extract_kotlin_bases(CBMArena *a, TSNode node, const char *source) { + base_class_list_t bases = {0}; + uint32_t nc = ts_node_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + if (!collect_kotlin_delegations(a, ts_node_child(node, i), source, &bases)) { + return NULL; + } } - result[count] = NULL; - return result; + return base_class_list_finish(a, &bases); } // Walk a field node and collect type identifier names into out[]. // Handles: direct type_identifier/generic_type/qualified_name, type_list children // (Java interfaces list), and raw text fallback (other languages). -static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char *source, - const char **out, int out_cap) { - int count = 0; +static bool collect_bases_from_field(CBMArena *a, TSNode field_node, const char *source, + base_class_list_t *out) { + size_t initial_count = out->count; const char *fk = ts_node_type(field_node); // If the field node itself is a type node, extract directly. @@ -2378,16 +2553,16 @@ static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char * if (angle) { *angle = '\0'; } - if (t[0] && count < out_cap) { - out[count++] = t; + if (!base_class_list_push(a, out, t)) { + return false; } } - return count; + return true; } // Walk named children: look for type identifiers or type_list/interface_type_list. uint32_t nc = ts_node_named_child_count(field_node); - for (uint32_t i = 0; i < nc && count < out_cap; i++) { + for (uint32_t i = 0; i < nc; i++) { TSNode child = ts_node_named_child(field_node, i); const char *ck = ts_node_type(child); if (strcmp(ck, "type_identifier") == 0 || strcmp(ck, "generic_type") == 0 || @@ -2406,7 +2581,9 @@ static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char * *angle = '\0'; } if (t[0]) { - out[count++] = t; + if (!base_class_list_push(a, out, t)) { + return false; + } } } } else if (strcmp(ck, "subscript") == 0) { @@ -2420,13 +2597,15 @@ static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char * if (!ts_node_is_null(val)) { char *t = cbm_node_text(a, val, source); if (t && t[0]) { - out[count++] = t; + if (!base_class_list_push(a, out, t)) { + return false; + } } } } else if (strcmp(ck, "type_list") == 0 || strcmp(ck, "interface_type_list") == 0) { // Java: super_interfaces contains type_list with multiple type_identifiers. uint32_t tlnc = ts_node_named_child_count(child); - for (uint32_t ti = 0; ti < tlnc && count < out_cap; ti++) { + for (uint32_t ti = 0; ti < tlnc; ti++) { TSNode tl_child = ts_node_named_child(child, ti); const char *tlk = ts_node_type(tl_child); if (strcmp(tlk, "type_identifier") == 0 || strcmp(tlk, "generic_type") == 0 || @@ -2438,7 +2617,9 @@ static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char * *angle = '\0'; } if (t[0]) { - out[count++] = t; + if (!base_class_list_push(a, out, t)) { + return false; + } } } } @@ -2447,14 +2628,14 @@ static int collect_bases_from_field(CBMArena *a, TSNode field_node, const char * } // Fallback: raw node text (for languages where the field node is the type name directly). - if (count == 0) { + if (out->count == initial_count) { char *t = cbm_node_text(a, field_node, source); - if (t && t[0] && count < out_cap) { - out[count++] = t; + if (!base_class_list_push(a, out, t)) { + return false; } } - return count; + return true; } // Extract base class names from a class node. @@ -2491,29 +2672,18 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s if (lang == CBM_LANG_OBJECTSCRIPT_UDL) { TSNode ext = cbm_find_child_by_kind(node, "class_extends"); if (!ts_node_is_null(ext)) { - const char *bases[MAX_BASES]; - int base_count = 0; + base_class_list_t bases = {0}; uint32_t nc = ts_node_named_child_count(ext); - for (uint32_t i = 0; i < nc && base_count < MAX_BASES_MINUS_1; i++) { + for (uint32_t i = 0; i < nc; i++) { TSNode ch = ts_node_named_child(ext, i); if (strcmp(ts_node_type(ch), "class_name") == 0) { char *base = cbm_node_text(a, ch, source); - if (base && base[0]) { - bases[base_count++] = base; + if (!base_class_list_push(a, &bases, base)) { + return NULL; } } } - if (base_count > 0) { - const char **result = - (const char **)cbm_arena_alloc(a, (base_count + 1) * sizeof(const char *)); - if (result) { - for (int i = 0; i < base_count; i++) { - result[i] = bases[i]; - } - result[base_count] = NULL; - return result; - } - } + return base_class_list_finish(a, &bases); } return NULL; } @@ -2591,40 +2761,31 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s /* D: `class Dog : Animal, IFoo` — class_declaration lists one `base_class` * child per base, each wrapping an identifier/qualified name. */ if (lang == CBM_LANG_DLANG) { - const char *pbases[MAX_BASES]; - int pc = 0; + base_class_list_t bases = {0}; uint32_t nc = ts_node_child_count(node); - for (uint32_t i = 0; i < nc && pc < MAX_BASES_MINUS_1; i++) { + for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_child(node, i); if (strcmp(ts_node_type(c), "base_class") != 0) { continue; } char *bn = cbm_node_text(a, c, source); - if (bn && bn[0]) { - pbases[pc++] = bn; + if (!base_class_list_push(a, &bases, bn)) { + return NULL; } } - if (pc > 0) { - const char **result = - (const char **)cbm_arena_alloc(a, (pc + NULL_TERM) * sizeof(const char *)); - if (result) { - for (int i = 0; i < pc; i++) { - result[i] = pbases[i]; - } - result[pc] = NULL; - return result; - } + const char **result = base_class_list_finish(a, &bases); + if (result) { + return result; } } /* PowerShell: `class Dog : Animal` — class_statement lists `simple_name` * children with a `:` token separating the class name from the base name(s). * Collect every simple_name that appears AFTER the first `:` token. */ if (lang == CBM_LANG_POWERSHELL && strcmp(ts_node_type(node), "class_statement") == 0) { - const char *pbases[MAX_BASES]; - int pc = 0; + base_class_list_t bases = {0}; bool seen_colon = false; uint32_t nc = ts_node_child_count(node); - for (uint32_t i = 0; i < nc && pc < MAX_BASES_MINUS_1; i++) { + for (uint32_t i = 0; i < nc; i++) { TSNode c = ts_node_child(node, i); const char *ck = ts_node_type(c); if (strcmp(ck, ":") == 0) { @@ -2636,30 +2797,22 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s } if (seen_colon && strcmp(ck, "simple_name") == 0) { char *bn = cbm_node_text(a, c, source); - if (bn && bn[0]) { - pbases[pc++] = bn; + if (!base_class_list_push(a, &bases, bn)) { + return NULL; } } } - if (pc > 0) { - const char **result = - (const char **)cbm_arena_alloc(a, (pc + NULL_TERM) * sizeof(const char *)); - if (result) { - for (int i = 0; i < pc; i++) { - result[i] = pbases[i]; - } - result[pc] = NULL; - return result; - } + const char **result = base_class_list_finish(a, &bases); + if (result) { + return result; } } /* Pascal: declClass carries one or more `parent` fields, each a `typeref` * (`= class(TBase, IFoo)`). Collect all parent typeref identifiers. */ if (lang == CBM_LANG_PASCAL && strcmp(ts_node_type(node), "declClass") == 0) { - const char *pbases[MAX_BASES]; - int pc = 0; + base_class_list_t bases = {0}; uint32_t nc = ts_node_child_count(node); - for (uint32_t i = 0; i < nc && pc < MAX_BASES_MINUS_1; i++) { + for (uint32_t i = 0; i < nc; i++) { const char *fn = ts_node_field_name_for_child(node, i); if (!fn || strcmp(fn, "parent") != 0) { continue; @@ -2669,20 +2822,13 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s continue; /* the '(' / ')' delimiters are also tagged `parent` */ } char *bn = cbm_node_text(a, pn, source); - if (bn && bn[0]) { - pbases[pc++] = bn; + if (!base_class_list_push(a, &bases, bn)) { + return NULL; } } - if (pc > 0) { - const char **result = - (const char **)cbm_arena_alloc(a, (pc + NULL_TERM) * sizeof(const char *)); - if (result) { - for (int i = 0; i < pc; i++) { - result[i] = pbases[i]; - } - result[pc] = NULL; - return result; - } + const char **result = base_class_list_finish(a, &bases); + if (result) { + return result; } } static const char *fields[] = {"superclass", @@ -2695,14 +2841,14 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s NULL}; // Collect all bases from all matching fields (fixes early-return bug and keyword-text bug). - const char *bases[MAX_BASES]; - int base_count = 0; + base_class_list_t bases = {0}; for (const char **f = fields; *f; f++) { TSNode super = ts_node_child_by_field_name(node, *f, (uint32_t)strlen(*f)); if (!ts_node_is_null(super)) { - base_count += collect_bases_from_field(a, super, source, bases + base_count, - MAX_BASES_MINUS_1 - base_count); + if (!collect_bases_from_field(a, super, source, &bases)) { + return NULL; + } } } @@ -2711,27 +2857,21 @@ static const char **extract_base_classes(CBMArena *a, TSNode node, const char *s // Without this the interface's bases were never captured. static const char *heritage_children[] = {"extends_interfaces", "super_interfaces", NULL}; uint32_t top_count = ts_node_child_count(node); - for (uint32_t i = 0; i < top_count && base_count < MAX_BASES_MINUS_1; i++) { + for (uint32_t i = 0; i < top_count; i++) { TSNode child = ts_node_child(node, i); const char *ck = ts_node_type(child); for (const char **h = heritage_children; *h; h++) { if (strcmp(ck, *h) == 0) { - base_count += collect_bases_from_field(a, child, source, bases + base_count, - MAX_BASES_MINUS_1 - base_count); + if (!collect_bases_from_field(a, child, source, &bases)) { + return NULL; + } } } } - if (base_count > 0) { - const char **result = - (const char **)cbm_arena_alloc(a, (base_count + NULL_TERM) * sizeof(const char *)); - if (result) { - for (int i = 0; i < base_count; i++) { - result[i] = bases[i]; - } - result[base_count] = NULL; - return result; - } + const char **field_result = base_class_list_finish(a, &bases); + if (field_result) { + return field_result; } // C/C++: handle base_class_clause @@ -2915,7 +3055,7 @@ static const char **extract_param_names(CBMArena *a, TSNode params, const char * // Parses Go-style multi-return (T1, T2) and single return types. // Returns NULL-terminated arena-allocated array. // Clean a type text and add to types array if valid. -static void add_cleaned_type(CBMArena *a, const char **types, int *count, char *type_text) { +static void add_cleaned_type(CBMArena *a, const char **types, size_t *count, char *type_text) { if (!type_text || !type_text[0]) { return; } @@ -2927,13 +3067,10 @@ static void add_cleaned_type(CBMArena *a, const char **types, int *count, char * // Extract Go multi-return types from a parameter_list result node. static void extract_go_multi_return(CBMArena *a, TSNode rt_node, const char *source, - const char **types, int *count) { - uint32_t nc = ts_node_child_count(rt_node); - for (uint32_t i = 0; i < nc && *count < MAX_RETURN_TYPES_MINUS_1; i++) { - TSNode child = ts_node_child(rt_node, i); - if (ts_node_is_null(child) || !ts_node_is_named(child)) { - continue; - } + const char **types, size_t *count) { + uint32_t nc = ts_node_named_child_count(rt_node); + for (uint32_t i = 0; i < nc; i++) { + TSNode child = ts_node_named_child(rt_node, i); if (strcmp(ts_node_type(child), "parameter_declaration") == 0) { TSNode tn = ts_node_child_by_field_name(child, TS_FIELD("type")); if (!ts_node_is_null(tn)) { @@ -2945,37 +3082,39 @@ static void extract_go_multi_return(CBMArena *a, TSNode rt_node, const char *sou } } -// Build a NULL-terminated arena-allocated string array from a types buffer. -static const char **build_type_array(CBMArena *a, const char **types, int count) { - if (count == 0) { +static const char **extract_return_types(CBMExtractCtx *ctx, TSNode rt_node) { + if (ts_node_is_null(rt_node)) { return NULL; } - const char **result = - (const char **)cbm_arena_alloc(a, (count + NULL_TERM) * sizeof(const char *)); - for (int i = 0; i < count; i++) { - result[i] = types[i]; - } - result[count] = NULL; - return result; -} -static const char **extract_return_types(CBMArena *a, TSNode rt_node, const char *source, - CBMLanguage lang) { - (void)lang; - if (ts_node_is_null(rt_node)) { + bool multi_return = strcmp(ts_node_type(rt_node), "parameter_list") == 0; + size_t capacity = multi_return ? (size_t)ts_node_named_child_count(rt_node) : SKIP_ONE; + if (capacity == 0) { return NULL; } + if (capacity > SIZE_MAX / sizeof(const char *) - NULL_TERM) { + ctx->result->has_error = true; + return NULL; + } + const char **types = cbm_arena_alloc(ctx->arena, (capacity + NULL_TERM) * sizeof(*types)); + if (!types) { + ctx->result->has_error = true; + return NULL; + } + size_t count = 0; - const char *types[MAX_RETURN_TYPES]; - int count = 0; - - if (strcmp(ts_node_type(rt_node), "parameter_list") == 0) { - extract_go_multi_return(a, rt_node, source, types, &count); + if (multi_return) { + extract_go_multi_return(ctx->arena, rt_node, ctx->source, types, &count); } else { - add_cleaned_type(a, types, &count, cbm_node_text(a, rt_node, source)); + add_cleaned_type(ctx->arena, types, &count, + cbm_node_text(ctx->arena, rt_node, ctx->source)); } - return build_type_array(a, types, count); + if (count == 0) { + return NULL; + } + types[count] = NULL; + return types; } // Extract param_types from a parameter list node. @@ -3144,6 +3283,122 @@ static TSNode find_c_params(TSNode func_node) { return null_node; } +static bool c_return_type_size_add(size_t *total, size_t addition) { + if (addition > SIZE_MAX - *total) { + return false; + } + *total += addition; + return true; +} + +/* C-family grammars split a function's declared return type across sibling + * type/type_qualifier nodes and the declarator chain. Preserve the exact base + * spelling, leading qualifiers, and every pointer layer without imposing a + * depth cap: the walk follows one strict child chain, so runtime is O(D) and + * temporary memory is O(return-type bytes), where D is declarator depth. */ +static char *c_declarator_return_type_text(CBMExtractCtx *ctx, TSNode func_node, TSNode type_node) { + uint32_t base_start = ts_node_start_byte(type_node); + uint32_t base_end = ts_node_end_byte(type_node); + if (base_end <= base_start) { + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + + TSNode declarator = ts_node_child_by_field_name(func_node, TS_FIELD("declarator")); + if (ts_node_is_null(declarator)) { + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + + size_t pointer_count = 0; + for (TSNode node = declarator; !ts_node_is_null(node); + node = ts_node_child_by_field_name(node, TS_FIELD("declarator"))) { + if (strcmp(ts_node_type(node), "pointer_declarator") == 0) { + if (pointer_count == SIZE_MAX) { + ctx->result->has_error = true; + ctx->result->error_msg = + cbm_arena_strdup(ctx->arena, "C return-type pointer depth exceeds size limit"); + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + pointer_count++; + } + } + + uint32_t declarator_start = ts_node_start_byte(declarator); + size_t qualifier_bytes = 0; + size_t qualifier_count = 0; + uint32_t child_count = ts_node_named_child_count(func_node); + for (uint32_t i = 0; i < child_count; i++) { + TSNode child = ts_node_named_child(func_node, i); + if (strcmp(ts_node_type(child), "type_qualifier") != 0 || + ts_node_end_byte(child) > declarator_start) { + continue; + } + uint32_t start = ts_node_start_byte(child); + uint32_t end = ts_node_end_byte(child); + if (end > start) { + size_t len = (size_t)(end - start); + if (len > SIZE_MAX - qualifier_bytes || qualifier_count == SIZE_MAX) { + ctx->result->has_error = true; + ctx->result->error_msg = + cbm_arena_strdup(ctx->arena, "C return-type qualifier size exceeds limit"); + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + qualifier_bytes += len; + qualifier_count++; + } + } + + if (pointer_count == 0 && qualifier_count == 0) { + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + + size_t base_len = (size_t)(base_end - base_start); + size_t result_len = base_len; + if (!c_return_type_size_add(&result_len, qualifier_bytes) || + !c_return_type_size_add(&result_len, qualifier_count) || + (pointer_count > 0 && !c_return_type_size_add(&result_len, SKIP_ONE)) || + !c_return_type_size_add(&result_len, pointer_count) || + !c_return_type_size_add(&result_len, NULL_TERM)) { + ctx->result->has_error = true; + ctx->result->error_msg = + cbm_arena_strdup(ctx->arena, "C return-type metadata exceeds addressable size"); + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + char *result = cbm_arena_alloc(ctx->arena, result_len); + if (!result) { + ctx->result->has_error = true; + ctx->result->error_msg = + cbm_arena_strdup(ctx->arena, "C return-type metadata allocation failed"); + return cbm_node_text(ctx->arena, type_node, ctx->source); + } + + size_t pos = 0; + for (uint32_t i = 0; i < child_count; i++) { + TSNode child = ts_node_named_child(func_node, i); + if (strcmp(ts_node_type(child), "type_qualifier") != 0 || + ts_node_end_byte(child) > declarator_start) { + continue; + } + uint32_t start = ts_node_start_byte(child); + uint32_t end = ts_node_end_byte(child); + if (end <= start) { + continue; + } + size_t len = (size_t)(end - start); + memcpy(result + pos, ctx->source + start, len); + pos += len; + result[pos++] = ' '; + } + memcpy(result + pos, ctx->source + base_start, base_len); + pos += base_len; + if (pointer_count > 0) { + result[pos++] = ' '; + memset(result + pos, '*', pointer_count); + pos += pointer_count; + } + result[pos] = '\0'; + return result; +} + // 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, @@ -3173,14 +3428,21 @@ static void resolve_cpp_trailing_return(CBMArena *a, TSNode func_node, const cha } /* Compute and store the structural complexity metrics for a definition. */ -static void set_def_complexity(CBMDefinition *def, TSNode body, const CBMLangSpec *spec) { +static bool set_def_complexity(CBMExtractCtx *ctx, CBMDefinition *def, TSNode body, + const CBMLangSpec *spec) { cbm_complexity_t cx; - cbm_compute_complexity(body, spec->branching_node_types, &cx); + if (!cbm_compute_complexity(body, spec->branching_node_types, &cx)) { + ctx->result->has_error = true; + ctx->result->error_msg = + cbm_arena_strdup(ctx->arena, "complexity traversal allocation failed"); + return false; + } def->complexity = cx.cyclomatic; def->cognitive = cx.cognitive; def->loop_count = cx.loop_count; def->loop_depth = cx.loop_depth; def->max_access_depth = cx.max_access_depth; + return true; } /* Extract the bare type name from a Go method receiver node. @@ -3377,8 +3639,8 @@ 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_types = extract_return_types(a, rt, ctx->source, ctx->language); + def.return_type = c_declarator_return_type_text(ctx, func_node, rt); + def.return_types = extract_return_types(ctx, rt); break; } } @@ -3452,7 +3714,7 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // Rust: disambiguate cfg-gated twin functions by folding the #[cfg(...)] // predicate into the QN so both branches survive the graph upsert (#495). if (ctx->language == CBM_LANG_RUST) { - def.qualified_name = rust_cfg_qualified_name(a, def.qualified_name, def.decorators); + def.qualified_name = cbm_rust_cfg_qualified_name(a, node, ctx->source, def.qualified_name); def.is_test = rust_def_is_test(def.decorators); } @@ -3466,7 +3728,9 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec // Complexity if (spec->branching_node_types && spec->branching_node_types[0]) { - set_def_complexity(&def, node, spec); + if (!set_def_complexity(ctx, &def, node, spec)) { + return; + } } // MinHash fingerprint @@ -4005,9 +4269,8 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec const char *label = class_label_for_kind(kind); // Sway/WGSL: label struct defs as "Struct" and Sway `abi` blocks as - // "Interface". Scoped to these grammar-only languages so established - // struct-as-"Class" labeling (C++/Cap'n Proto …) and the downstream - // type/IMPLEMENTS resolvers that depend on it are unaffected. + // "Interface". C/C++/ObjC/Cap'n Proto keep historical struct-as-"Class" + // semantics because downstream class-like record resolvers depend on it. if (ctx->language == CBM_LANG_SWAY || ctx->language == CBM_LANG_WGSL) { if (strcmp(kind, "struct_item") == 0 || strcmp(kind, "struct_declaration") == 0) { label = "Struct"; @@ -4042,6 +4305,12 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec label = "Struct"; } } + /* Older grammar shapes may omit the field while retaining the keyword + * child; preserve that structural fallback for compatibility. */ + if (strcmp(label, "Struct") != 0 && + !ts_node_is_null(cbm_find_child_by_kind(node, "struct"))) { + label = "Struct"; + } } // F#: a `type_definition` that has a primary constructor (`type Foo(...) =`) // or an `inherit` clause is an OOP class, not a plain type alias. Label it @@ -4382,7 +4651,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_declarator_return_type_text(ctx, child, rt); break; } } @@ -4419,7 +4688,9 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, def.docstring = extract_docstring(a, child, ctx->source, ctx->language); if (spec->branching_node_types && spec->branching_node_types[0]) { - set_def_complexity(&def, child, spec); + if (!set_def_complexity(ctx, &def, child, spec)) { + return; + } } // MinHash fingerprint @@ -4625,7 +4896,9 @@ static void extract_rust_impl(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } if (spec->branching_node_types && spec->branching_node_types[0]) { - set_def_complexity(&def, child, spec); + if (!set_def_complexity(ctx, &def, child, spec)) { + return; + } } // MinHash fingerprint @@ -5519,11 +5792,16 @@ static void extract_nix_binding_set(CBMExtractCtx *ctx, TSNode set, const CBMLan * exported surface. Anything deeper is nested and deliberately skipped. */ static void extract_nix_module_vars(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec) { TSNode cur = ts_node_named_child_count(root) > 0 ? ts_node_named_child(root, 0) : root; - /* Descend header lambdas: `{ pkgs, ... }: `, `final: prev: `. */ - for (int hop = 0; hop < NIX_HEADER_HOP_MAX && !ts_node_is_null(cur) && - strcmp(ts_node_type(cur), "function_expression") == 0; - hop++) { - cur = ts_node_child_by_field_name(cur, TS_FIELD("body")); + /* Descend the finite AST chain of header lambdas: `{ pkgs, ... }: `, + * `final: prev: `. Each step moves to a strict child, so termination + * follows from the parsed tree without an arbitrary capability ceiling. + * Runtime is O(H), memory O(1), for H curried header lambdas. */ + while (!ts_node_is_null(cur) && strcmp(ts_node_type(cur), "function_expression") == 0) { + TSNode body = ts_node_child_by_field_name(cur, TS_FIELD("body")); + if (ts_node_is_null(body)) { + return; + } + cur = body; } if (ts_node_is_null(cur)) { return; @@ -6812,9 +7090,10 @@ static void extract_lisp_def(CBMExtractCtx *ctx, TSNode node) { * delegation class Tree { inner class Node : BaseNode() { ... } } // inner + * delegation * - * Inside the ERROR node the tokens are still present as a flat child list: - * `class`/`object` keyword token → simple_identifier/type_identifier (name) - * → optional `:` then one or more `delegation_specifier` siblings (bases). + * Depending on the grammar revision, declaration keywords may be flat children + * or omitted from the ERROR node while the declaration name remains as a direct + * identifier child. In the latter case, validate the source prefix before + * recovering the identifier so arbitrary syntax errors do not become classes. * * Recover each named class/object declaration from that flat sequence and emit a * Class definition (with bases) so it is discoverable. Strictly additive and @@ -6822,26 +7101,236 @@ static void extract_lisp_def(CBMExtractCtx *ctx, TSNode node) { * recovering names from it cannot regress a correct parse. Anonymous declarations * (e.g. a `companion object` with no name) are skipped — there is nothing to emit. */ +static bool kotlin_identifier_kind(const char *kind) { + return strcmp(kind, "identifier") == 0 || strcmp(kind, "simple_identifier") == 0 || + strcmp(kind, "type_identifier") == 0; +} + +static bool kotlin_source_ident_start(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; +} + +static bool kotlin_source_ident_continue(char c) { + return kotlin_source_ident_start(c) || (c >= '0' && c <= '9'); +} + +static bool kotlin_result_has_def_name(const CBMFileResult *result, const char *name) { + for (int i = 0; i < result->defs.count; i++) { + if (result->defs.items[i].name && strcmp(result->defs.items[i].name, name) == 0) { + return true; + } + } + return false; +} + +static bool kotlin_source_bases(CBMExtractCtx *ctx, uint32_t start, uint32_t end, + base_class_list_t *bases) { + const char *source = ctx->source; + int paren_depth = 0; + int angle_depth = 0; + uint32_t colon = end; + uint32_t header_end = end; + for (uint32_t i = start; i < end; i++) { + char c = source[i]; + if (c == '(') { + paren_depth++; + } else if (c == ')' && paren_depth > 0) { + paren_depth--; + } else if (c == '<') { + angle_depth++; + } else if (c == '>' && angle_depth > 0) { + angle_depth--; + } else if (paren_depth == 0 && angle_depth == 0 && c == ':' && colon == end) { + colon = i; + } else if (paren_depth == 0 && angle_depth == 0 && + (c == '{' || c == '=' || c == ';' || c == '}')) { + header_end = i; + break; + } + } + if (colon >= header_end) { + return true; + } + + uint32_t i = colon + 1; + while (i < header_end) { + while (i < header_end && (source[i] == ' ' || source[i] == '\t' || source[i] == '\r' || + source[i] == '\n' || source[i] == ',')) { + i++; + } + if (i >= header_end || !kotlin_source_ident_start(source[i])) { + break; + } + uint32_t name_start = i; + while (i < header_end && (kotlin_source_ident_continue(source[i]) || source[i] == '.')) { + i++; + } + char *base = cbm_arena_strndup(ctx->arena, source + name_start, (size_t)(i - name_start)); + if (base && base[0] && strcmp(base, "by") != 0) { + if (!base_class_list_push(ctx->arena, bases, base)) { + return false; + } + } + + paren_depth = 0; + angle_depth = 0; + while (i < header_end) { + char c = source[i]; + if (c == '(') { + paren_depth++; + } else if (c == ')' && paren_depth > 0) { + paren_depth--; + } else if (c == '<') { + angle_depth++; + } else if (c == '>' && angle_depth > 0) { + angle_depth--; + } else if (c == ',' && paren_depth == 0 && angle_depth == 0) { + i++; + break; + } + i++; + } + } + return true; +} + +static void recover_kotlin_error_source(CBMExtractCtx *ctx, TSNode err_node) { + const char *source = ctx->source; + uint32_t start = ts_node_start_byte(err_node); + uint32_t end = ts_node_end_byte(err_node); + if (end > (uint32_t)ctx->source_len) { + end = (uint32_t)ctx->source_len; + } + bool line_comment = false; + bool block_comment = false; + bool string_literal = false; + bool char_literal = false; + for (uint32_t i = start; i < end;) { + char c = source[i]; + char next = i + 1 < end ? source[i + 1] : '\0'; + if (line_comment) { + line_comment = c != '\n'; + i++; + continue; + } + if (block_comment) { + if (c == '*' && next == '/') { + block_comment = false; + i += 2; + } else { + i++; + } + continue; + } + if (string_literal || char_literal) { + char quote = string_literal ? '"' : '\''; + if (c == '\\' && i + 1 < end) { + i += 2; + } else { + if (c == quote) { + string_literal = false; + char_literal = false; + } + i++; + } + continue; + } + if (c == '/' && next == '/') { + line_comment = true; + i += 2; + continue; + } + if (c == '/' && next == '*') { + block_comment = true; + i += 2; + continue; + } + if (c == '"' || c == '\'') { + string_literal = c == '"'; + char_literal = c == '\''; + i++; + continue; + } + if (!kotlin_source_ident_start(c)) { + i++; + continue; + } + + uint32_t word_start = i++; + while (i < end && kotlin_source_ident_continue(source[i])) { + i++; + } + size_t word_len = (size_t)(i - word_start); + const char *label = NULL; + if (word_len == sizeof("interface") - 1 && + strncmp(source + word_start, "interface", word_len) == 0) { + label = "Interface"; + } else if ((word_len == sizeof("class") - 1 && + strncmp(source + word_start, "class", word_len) == 0) || + (word_len == sizeof("object") - 1 && + strncmp(source + word_start, "object", word_len) == 0)) { + label = "Class"; + } + if (!label) { + continue; + } + while (i < end && + (source[i] == ' ' || source[i] == '\t' || source[i] == '\r' || source[i] == '\n')) { + i++; + } + if (i >= end || !kotlin_source_ident_start(source[i])) { + continue; + } + uint32_t name_start = i++; + while (i < end && kotlin_source_ident_continue(source[i])) { + i++; + } + char *name = cbm_arena_strndup(ctx->arena, source + name_start, (size_t)(i - name_start)); + if (!name || !name[0] || kotlin_result_has_def_name(ctx->result, name)) { + continue; + } + + base_class_list_t bases = {0}; + if (!kotlin_source_bases(ctx, i, end, &bases)) { + ctx->result->has_error = true; + return; + } + CBMDefinition def; + memset(&def, 0, sizeof(def)); + def.name = name; + def.qualified_name = + ctx->enclosing_class_qn + ? cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->enclosing_class_qn, name) + : cbm_fqn_compute(ctx->arena, ctx->project, ctx->rel_path, name); + def.label = label; + def.file_path = ctx->rel_path; + def.start_line = ts_node_start_point(err_node).row + TS_LINE_OFFSET; + def.end_line = ts_node_end_point(err_node).row + TS_LINE_OFFSET; + def.is_exported = cbm_is_exported(name, ctx->language); + def.base_classes = base_class_list_finish(ctx->arena, &bases); + cbm_defs_push(&ctx->result->defs, ctx->arena, def); + } +} + static void recover_kotlin_error_classes(CBMExtractCtx *ctx, TSNode err_node) { CBMArena *a = ctx->arena; uint32_t cc = ts_node_child_count(err_node); for (uint32_t i = 0; i < cc; i++) { - TSNode kw = ts_node_child(err_node, i); - const char *kwt = ts_node_type(kw); - /* Anonymous `class` / `object` keyword token starts a declaration. */ - if (strcmp(kwt, "class") != 0 && strcmp(kwt, "object") != 0) { + TSNode keyword = ts_node_child(err_node, i); + const char *kind = ts_node_type(keyword); + if (strcmp(kind, "class") != 0 && strcmp(kind, "object") != 0 && + strcmp(kind, "interface") != 0) { continue; } - /* The name is the next child, when it is an identifier token. */ if (i + 1 >= cc) { continue; } TSNode name_node = ts_node_child(err_node, i + 1); - const char *nt = ts_node_type(name_node); - if (strcmp(nt, "simple_identifier") != 0 && strcmp(nt, "type_identifier") != 0) { - /* Anonymous declaration (e.g. `companion object :`) — nothing to emit. */ + if (!kotlin_identifier_kind(ts_node_type(name_node))) { continue; } + const char *label = strcmp(kind, "interface") == 0 ? "Interface" : "Class"; + uint32_t base_start = i + 2; char *name = cbm_node_text(a, name_node, ctx->source); if (!name || !name[0]) { continue; @@ -6856,55 +7345,33 @@ static void recover_kotlin_error_classes(CBMExtractCtx *ctx, TSNode err_node) { /* Collect bases from any `delegation_specifier` siblings that follow the * name (until the class body `{` or the next class/object keyword). */ - const char *bases[MAX_BASES]; - int bcount = 0; - for (uint32_t j = i + 2; j < cc && bcount < MAX_BASES_MINUS_1; j++) { + base_class_list_t bases = {0}; + for (uint32_t j = base_start; j < cc; j++) { TSNode sib = ts_node_child(err_node, j); const char *st = ts_node_type(sib); if (strcmp(st, "{") == 0 || strcmp(st, "class") == 0 || strcmp(st, "object") == 0) { break; } - if (strcmp(st, "delegation_specifier") != 0) { - continue; - } - /* delegation_specifier → user_type (directly or under - * constructor_invocation) → type_identifier; strip generic args. */ - TSNode ut = ts_node_named_child(sib, 0); - if (!ts_node_is_null(ut) && strcmp(ts_node_type(ut), "constructor_invocation") == 0) { - ut = ts_node_named_child(ut, 0); - } - if (ts_node_is_null(ut)) { - continue; - } - TSNode ti = ut; - if (strcmp(ts_node_type(ut), "user_type") == 0 && ts_node_named_child_count(ut) > 0) { - ti = ts_node_named_child(ut, 0); + if (!collect_kotlin_delegations(a, sib, ctx->source, &bases)) { + ctx->result->has_error = true; + return; } - push_base_text(a, ti, ctx->source, bases, MAX_BASES_MINUS_1, &bcount); } CBMDefinition def; memset(&def, 0, sizeof(def)); def.name = name; def.qualified_name = class_qn; - def.label = "Class"; + def.label = label; def.file_path = ctx->rel_path; def.start_line = ts_node_start_point(name_node).row + TS_LINE_OFFSET; def.end_line = ts_node_end_point(err_node).row + TS_LINE_OFFSET; def.is_exported = cbm_is_exported(name, ctx->language); - if (bcount > 0) { - const char **result = (const char **)cbm_arena_alloc(a, (size_t)(bcount + NULL_TERM) * - sizeof(const char *)); - if (result) { - for (int k = 0; k < bcount; k++) { - result[k] = bases[k]; - } - result[bcount] = NULL; - def.base_classes = result; - } - } + def.base_classes = base_class_list_finish(a, &bases); cbm_defs_push(&ctx->result->defs, a, def); + i++; } + recover_kotlin_error_source(ctx, err_node); } static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, int depth_unused) { @@ -6936,7 +7403,7 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, (strcmp(kind, "preproc_def") == 0 || strcmp(kind, "preproc_function_def") == 0)) { // Gated to full/advanced index modes — macros dominate extraction on // macro-dense codebases (e.g. the Linux kernel). See #375. - if (cbm_macro_extraction_enabled()) { + if (ctx->extract_macros) { extract_c_macro_def(ctx, node); } continue; // the macro body is a preproc_arg — nothing more to extract diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 717068a2b..e23689984 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -716,6 +716,8 @@ static char *extract_ruby_require_arg(CBMArena *a, TSNode node, const char *sour static void parse_ruby_imports(CBMExtractCtx *ctx) { CBMArena *a = ctx->arena; + // Walk for call nodes with "require" or "require_relative" + // Walk top-level children via O(N) TSTreeCursor traversal TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); if (!ts_tree_cursor_goto_first_child(&cursor)) { ts_tree_cursor_delete(&cursor); @@ -961,11 +963,10 @@ static void parse_generic_imports(CBMExtractCtx *ctx, const char *node_type) { } // --- Kotlin imports --- -// tree-sitter-kotlin nests imports: source_file -> import_list -> import_header*. -// parse_generic_imports only scans the DIRECT children of root, and "import" is -// the keyword token (anon_sym_import), not a statement node — so a generic -// match on "import" finds nothing. Descend into import_list (and accept a bare -// import_header for grammar variants) and reuse the generic path extractors. +// Kotlin grammar revisions expose imports either directly as named `import` +// nodes or nested as source_file -> import_list -> import_header*. Accept both +// layouts while scanning only root children and (when present) one container +// level, keeping the pass linear in the number of top-level syntax nodes. static void extract_one_import_header(CBMExtractCtx *ctx, TSNode header) { if (!try_generic_path_fields(ctx, header)) { generic_import_from_text(ctx, header); @@ -981,13 +982,14 @@ static void parse_kotlin_imports(CBMExtractCtx *ctx) { do { TSNode node = ts_tree_cursor_current_node(&cursor); const char *kind = ts_node_type(node); - if (strcmp(kind, "import_header") == 0) { + if (strcmp(kind, "import") == 0 || strcmp(kind, "import_header") == 0) { extract_one_import_header(ctx, node); } else if (strcmp(kind, "import_list") == 0) { uint32_t nc = ts_node_child_count(node); for (uint32_t j = 0; j < nc; j++) { TSNode child = ts_node_child(node, j); - if (strcmp(ts_node_type(child), "import_header") == 0) { + const char *child_kind = ts_node_type(child); + if (strcmp(child_kind, "import") == 0 || strcmp(child_kind, "import_header") == 0) { extract_one_import_header(ctx, child); } } @@ -1384,38 +1386,96 @@ static void parse_spec_imports(CBMExtractCtx *ctx) { // that the main parser uses. Adding another host language is a one-line // declaration in lang_specs.c. -static void embedded_collect_content_nodes(TSNode root, const CBMEmbeddedLangSpec *spec, - TSNode *out, int *out_count, int max_out) { - /* Iterative DFS so deeply-nested script blocks are still found. Cap the - * stack to a sane bound (host grammars do not have million-deep markup - * trees) — no need to introduce TSNodeStack here. */ - enum { EMBED_STACK_CAP = 1024 }; - TSNode stack[EMBED_STACK_CAP]; - int top = 0; - stack[top++] = root; - while (top > 0 && *out_count < max_out) { - TSNode node = stack[--top]; - const char *kind = ts_node_type(node); - if (strcmp(kind, spec->script_node_type) == 0) { - uint32_t cc = ts_node_child_count(node); - for (uint32_t k = 0; k < cc; k++) { - TSNode c = ts_node_child(node, k); - if (strcmp(ts_node_type(c), spec->content_node_type) == 0) { - out[(*out_count)++] = c; - if (*out_count >= max_out) { - return; +static bool parse_embedded_content(CBMExtractCtx *ctx, TSParser *parser, TSNode content) { + uint32_t start = ts_node_start_byte(content); + uint32_t end = ts_node_end_byte(content); + if (end <= start || end > (uint32_t)ctx->source_len) { + return true; + } + const char *sub_source = ctx->source + start; + uint32_t sub_length = end - start; + TSTree *sub_tree = ts_parser_parse_string(parser, NULL, sub_source, sub_length); + if (!sub_tree) { + return false; + } + CBMExtractCtx sub_ctx = *ctx; + sub_ctx.source = sub_source; + sub_ctx.source_len = (int)sub_length; + sub_ctx.root = ts_tree_root_node(sub_tree); + walk_es_imports(&sub_ctx, sub_ctx.root); + ts_tree_delete(sub_tree); + return true; +} + +typedef enum { + EMBEDDED_WALK_OK = 0, + EMBEDDED_WALK_PARSER_ALLOCATION_FAILED, + EMBEDDED_WALK_PARSE_ALLOCATION_FAILED, +} embedded_walk_status_t; + +/* + * Stream matching content nodes directly into one lazily-created embedded + * parser. The cursor visits the host AST in O(N) time with O(1) auxiliary + * memory, avoids both a fixed traversal frontier and a fixed script-result + * prefix, and preserves the allocation-free path for hosts without scripts. + */ +static embedded_walk_status_t walk_embedded_content_nodes(CBMExtractCtx *ctx, + const CBMEmbeddedLangSpec *spec, + const TSLanguage *embedded_lang) { + TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); + TSParser *parser = NULL; + for (;;) { + TSNode node = ts_tree_cursor_current_node(&cursor); + bool descend = true; + if (strcmp(ts_node_type(node), spec->script_node_type) == 0) { + uint32_t child_count = ts_node_child_count(node); + for (uint32_t i = 0; i < child_count; i++) { + TSNode child = ts_node_child(node, i); + if (strcmp(ts_node_type(child), spec->content_node_type) == 0) { + if (!parser) { + parser = ts_parser_new(); + if (!parser) { + ts_tree_cursor_delete(&cursor); + return EMBEDDED_WALK_PARSER_ALLOCATION_FAILED; + } + if (!ts_parser_set_language(parser, embedded_lang)) { + ts_parser_delete(parser); + ts_tree_cursor_delete(&cursor); + return EMBEDDED_WALK_OK; + } + } + if (!parse_embedded_content(ctx, parser, child)) { + ts_parser_delete(parser); + ts_tree_cursor_delete(&cursor); + return EMBEDDED_WALK_PARSE_ALLOCATION_FAILED; } break; /* one content node per script element */ } } - /* Do not descend into \n", i, i); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(source) - used); + used += (size_t)n; + } + n = snprintf(source + used, sizeof(source) - used, "\n"); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(source) - used); + + CBMFileResult *r = extract(source, CBM_LANG_HTML, "t", "many-scripts.html"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_EQ(r->imports.count, SCRIPT_BLOCK_COUNT); + ASSERT(has_import(r, "module19.js")); + + cbm_free_result(r); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * config_extraction_test.go ports (25 tests) * ═══════════════════════════════════════════════════════════════════ */ @@ -3085,34 +3901,27 @@ TEST(extract_java_method_annotations_issue382) { PASS(); } -/* Issue #1005: JAX-RS splits a route across two annotations (@GET carries the - * verb, a sibling @Path carries the path). Returning on the first mapping - * annotation dropped every method-level @Path, and the class-level @Path - * prefix was never recognized at all. */ -TEST(extract_java_jaxrs_path_composition_issue1005) { - CBMFileResult *r = extract("import jakarta.ws.rs.GET;\n" - "import jakarta.ws.rs.Path;\n" - "@Path(\"/api/v1/widgets\")\n" - "public class WidgetResource {\n" - " @GET\n" - " public String list() { return \"\"; }\n" - " @GET\n" - " @Path(\"/count\")\n" - " public String count() { return \"\"; }\n" - "}\n", - CBM_LANG_JAVA, "t", "WidgetResource.java"); +TEST(extract_python_mock_patch_is_not_route) { + CBMFileResult *r = extract("from unittest.mock import patch\n\n" + "@patch(\"subprocess.run\")\n" + "def test_cmd(mock_run):\n" + " pass\n\n" + "@app.patch(\"/items/{id}\")\n" + "def update_item():\n" + " pass\n", + CBM_LANG_PYTHON, "t", "test_routes.py"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); - const CBMDefinition *list = find_def_by_name(r, "list"); - ASSERT_NOT_NULL(list); - ASSERT_NOT_NULL(list->route_path); - ASSERT_STR_EQ(list->route_path, "/api/v1/widgets"); - ASSERT_STR_EQ(list->route_method, "GET"); - const CBMDefinition *count = find_def_by_name(r, "count"); - ASSERT_NOT_NULL(count); - ASSERT_NOT_NULL(count->route_path); - ASSERT_STR_EQ(count->route_path, "/api/v1/widgets/count"); - ASSERT_STR_EQ(count->route_method, "GET"); + + const CBMDefinition *mocked = find_def_by_name(r, "test_cmd"); + ASSERT_NOT_NULL(mocked); + ASSERT_NULL(mocked->route_path); + ASSERT_NULL(mocked->route_method); + + const CBMDefinition *route = find_def_by_name(r, "update_item"); + ASSERT_NOT_NULL(route); + ASSERT_STR_EQ(route->route_path, "/items/{id}"); + ASSERT_STR_EQ(route->route_method, "PATCH"); cbm_free_result(r); PASS(); } @@ -3307,6 +4116,71 @@ static const CBMDefinition *find_def(CBMFileResult *r, const char *name) { return NULL; } +static bool compute_go_ast_profile(const char *source, const char **param_names, int param_count, + cbm_ast_profile_t *profile) { + const TSLanguage *language = cbm_ts_language(CBM_LANG_GO); + if (!language) { + return false; + } + TSParser *parser = ts_parser_new(); + if (!parser) { + return false; + } + if (!ts_parser_set_language(parser, language)) { + ts_parser_delete(parser); + return false; + } + TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source)); + if (!tree) { + ts_parser_delete(parser); + return false; + } + bool computed = + cbm_ast_profile_compute(ts_tree_root_node(tree), source, param_names, param_count, profile); + ts_tree_delete(tree); + ts_parser_delete(parser); + return computed; +} + +TEST(extract_go_retains_all_multi_return_types) { + enum { RETURN_TYPE_TEST_COUNT = 20 }; + char source[CBM_SZ_2K]; + size_t used = 0; + int n = snprintf(source, sizeof(source), "package p\nfunc fanout() ("); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(source)); + used = (size_t)n; + for (int i = 0; i < RETURN_TYPE_TEST_COUNT; i++) { + n = snprintf(source + used, sizeof(source) - used, "%sT%02d", i == 0 ? "" : ", ", i); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(source) - used); + used += (size_t)n; + } + n = snprintf(source + used, sizeof(source) - used, ") { panic(\"not implemented\") }\n"); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(source) - used); + + CBMFileResult *r = extract(source, CBM_LANG_GO, "t", "fanout.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *fanout = find_def(r, "fanout"); + ASSERT_NOT_NULL(fanout); + ASSERT_NOT_NULL(fanout->return_types); + int return_count = 0; + while (fanout->return_types[return_count]) { + char expected[CBM_SZ_16]; + n = snprintf(expected, sizeof(expected), "T%02d", return_count); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(expected)); + ASSERT_STR_EQ(fanout->return_types[return_count], expected); + return_count++; + } + ASSERT_EQ(return_count, RETURN_TYPE_TEST_COUNT); + + cbm_free_result(r); + PASS(); +} + TEST(complexity_nested_loops_depth) { CBMFileResult *r = extract("package p\n" "func deepLoops() {\n" @@ -3329,6 +4203,101 @@ TEST(complexity_nested_loops_depth) { PASS(); } +/* + * Complexity metrics are graph data, not a preview: a wide function must not + * silently omit branches after an internal traversal working set fills. + */ +TEST(complexity_retains_branches_beyond_4096_siblings) { + enum { BRANCH_COUNT = 5000 }; + static const char branch_source[] = "if x {}\n"; + size_t capacity = (size_t)BRANCH_COUNT * (sizeof(branch_source) - SKIP_ONE) + CBM_SZ_64; + char *source = (char *)malloc(capacity); + ASSERT_NOT_NULL(source); + size_t used = 0; + int n = snprintf(source, capacity, "package p\nfunc wide(x bool) {\n"); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, capacity); + used = (size_t)n; + for (int i = 0; i < BRANCH_COUNT; i++) { + n = snprintf(source + used, capacity - used, "%s", branch_source); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, capacity - used); + used += (size_t)n; + } + n = snprintf(source + used, capacity - used, "}\n"); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, capacity - used); + + CBMFileResult *r = extract(source, CBM_LANG_GO, "t", "wide.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *wide = find_def(r, "wide"); + ASSERT_NOT_NULL(wide); + ASSERT_EQ(wide->complexity, BRANCH_COUNT); + ASSERT_EQ(wide->cognitive, BRANCH_COUNT); + ASSERT_EQ(wide->loop_count, 0); + ASSERT_EQ(wide->loop_depth, 0); + + cbm_free_result(r); + free(source); + PASS(); +} + +/* + * Structural profiles feed semantic similarity, so their control-flow counts + * must describe the whole function rather than a fixed traversal prefix. + */ +TEST(ast_profile_retains_if_nodes_beyond_2048_frontier) { + enum { IF_COUNT = 5000 }; + static const char if_source[] = "if x {}\n"; + size_t capacity = (size_t)IF_COUNT * (sizeof(if_source) - SKIP_ONE) + CBM_SZ_64; + char *source = (char *)malloc(capacity); + ASSERT_NOT_NULL(source); + size_t used = 0; + int n = snprintf(source, capacity, "package p\nfunc profileWide(x bool) {\n"); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, capacity); + used = (size_t)n; + for (int i = 0; i < IF_COUNT; i++) { + n = snprintf(source + used, capacity - used, "%s", if_source); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, capacity - used); + used += (size_t)n; + } + n = snprintf(source + used, capacity - used, "}\n"); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, capacity - used); + + cbm_ast_profile_t profile; + ASSERT_TRUE(compute_go_ast_profile(source, NULL, 0, &profile)); + ASSERT_EQ(profile.if_count, IF_COUNT); + + free(source); + PASS(); +} + +/* + * Parameter-flow signals describe identifier ancestry. A parameter in an if + * condition and a parameter below a return statement must each be attributed + * to the corresponding syntax field/scope. + */ +TEST(ast_profile_tracks_parameter_context) { + static const char source[] = "package p\n" + "func choose(x bool) bool {\n" + " if x {\n" + " return x\n" + " }\n" + " return false\n" + "}\n"; + static const char *param_names[] = {"x"}; + cbm_ast_profile_t profile; + ASSERT_TRUE(compute_go_ast_profile(source, param_names, 1, &profile)); + ASSERT_EQ(profile.params_in_conditions, 1); + ASSERT_EQ(profile.params_in_returns, 1); + + PASS(); +} + TEST(complexity_loop_with_branch) { CBMFileResult *r = extract("package p\n" "func single() {\n" @@ -3833,6 +4802,31 @@ TEST(extract_js_member_call_flags_is_method) { PASS(); } +TEST(extract_rust_macro_invocation_is_not_an_ordinary_call) { + CBMFileResult *r = extract("fn matches(_v: bool) -> bool { true }\n" + "fn run(v: bool) -> bool { matches!(v, true) && matches(v) }\n", + CBM_LANG_RUST, "t", "x.rs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + int macro_calls = 0; + int ordinary_calls = 0; + for (int i = 0; i < r->calls.count; i++) { + CBMCall *call = &r->calls.items[i]; + if (!call->callee_name || strcmp(call->callee_name, "matches") != 0) { + continue; + } + if (call->is_macro_invocation) { + macro_calls++; + } else { + ordinary_calls++; + } + } + ASSERT_EQ(macro_calls, 1); + ASSERT_EQ(ordinary_calls, 1); + cbm_free_result(r); + PASS(); +} + /* #961: a C function whose body braces are split across #ifdef/#else * branches (one open brace per branch, a single shared close) parses with * an ERROR region on the raw source — both branches are present at once — @@ -4820,6 +5814,7 @@ TEST(objectscript_macro_expand_local) { ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT(has_call(r, "MyApp.Utils.Validate")); + ASSERT(has_call_enclosing(r, "MyApp.Utils.Validate", "MyApp.Caller.Run", NULL)); cbm_free_result(r); cbm_arena_destroy(&arena); PASS(); @@ -5028,6 +6023,86 @@ TEST(iris_export_xml_multi_class) { PASS(); } +/* ── Restored from the merge base ───────────────────────────────────── + * These three lost their tests when this file auto-merged, with no conflict + * raised. Every capability they cover still ships: route_path/route_method + * are populated in internal/cbm/extract_defs.c and internal/cbm/service_patterns.c + * (which still carries the jakarta/ws.rs handling), and parse_incomplete is + * still a CBMFileResult field in internal/cbm/cbm.h. The macro pair is a + * matched set and must stay together: one asserts a benign in-body macro call + * does NOT report a coverage gap, the other asserts a real syntax error in a + * body STILL does. Keeping only the suppressing half would let over-broad + * suppression pass unnoticed. */ + +TEST(extract_java_jaxrs_path_composition_issue1005) { + CBMFileResult *r = extract("import jakarta.ws.rs.GET;\n" + "import jakarta.ws.rs.Path;\n" + "@Path(\"/api/v1/widgets\")\n" + "public class WidgetResource {\n" + " @GET\n" + " public String list() { return \"\"; }\n" + " @GET\n" + " @Path(\"/count\")\n" + " public String count() { return \"\"; }\n" + "}\n", + CBM_LANG_JAVA, "t", "WidgetResource.java"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *list = find_def_by_name(r, "list"); + ASSERT_NOT_NULL(list); + ASSERT_NOT_NULL(list->route_path); + ASSERT_STR_EQ(list->route_path, "/api/v1/widgets"); + ASSERT_STR_EQ(list->route_method, "GET"); + const CBMDefinition *count = find_def_by_name(r, "count"); + ASSERT_NOT_NULL(count); + ASSERT_NOT_NULL(count->route_path); + ASSERT_STR_EQ(count->route_path, "/api/v1/widgets/count"); + ASSERT_STR_EQ(count->route_method, "GET"); + cbm_free_result(r); + PASS(); +} + +TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071) { + CBMFileResult *r = extract("#include \n" + "#include \n" + "\n" + "#define SYNTH_ALLOC_ARRAY(Type, Count) \\\n" + " ((Type*)std::malloc(sizeof(Type) * (Count)))\n" + "\n" + "struct Buffer {\n" + " char* data;\n" + " std::size_t size;\n" + "};\n" + "\n" + "Buffer make_buffer(std::size_t n) {\n" + " Buffer b;\n" + " b.data = SYNTH_ALLOC_ARRAY(char, n);\n" + " b.size = n;\n" + " return b;\n" + "}\n", + CBM_LANG_CPP, "t", "alloc.cpp"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->parse_incomplete); /* benign in-body macro call — not a coverage gap */ + ASSERT(has_def(r, "Function", "make_buffer")); + ASSERT(has_def(r, "Macro", "SYNTH_ALLOC_ARRAY")); + cbm_free_result(r); + PASS(); +} + +TEST(extract_cpp_real_in_body_error_still_flagged_issue1071) { + /* `int x = ;` is a genuine syntax error inside foo()'s body — no macro + * involved, so the coverage gap must not be suppressed. */ + CBMFileResult *r = extract("int foo() {\n" + " int x = ;\n" + " return x;\n" + "}\n", + CBM_LANG_CPP, "t", "broken.cpp"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(r->parse_incomplete); /* real gap stays reported */ + cbm_free_result(r); + PASS(); +} + SUITE(extraction) { /* Initialize extraction library */ cbm_init(); @@ -5043,6 +6118,7 @@ SUITE(extraction) { RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); + RUN_TEST(extract_rust_macro_invocation_is_not_an_ordinary_call); /* InterSystems ObjectScript (UDL / routine / Export XML). */ RUN_TEST(objectscript_udl_class); @@ -5084,8 +6160,8 @@ SUITE(extraction) { RUN_TEST(extract_ts_factory_object_methods_issue341); RUN_TEST(extract_c_macros_issue375); RUN_TEST(extract_cpp_macros_issue375); - RUN_TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071); - RUN_TEST(extract_cpp_real_in_body_error_still_flagged_issue1071); + RUN_TEST(extract_c_macro_option_is_per_call); + RUN_TEST(extract_c_macro_expanded_pass_is_calls_only); RUN_TEST(extract_gdscript_issue186); RUN_TEST(extract_powershell_issue35); RUN_TEST(extract_luau_issue39); @@ -5099,6 +6175,7 @@ SUITE(extraction) { RUN_TEST(java_class); RUN_TEST(java_method); RUN_TEST(java_interface); + RUN_TEST(java_method_reference_emits_call_site); RUN_TEST(java_class_extends_and_implements); RUN_TEST(python_class_base_extracted_bare); RUN_TEST(php_class); @@ -5110,6 +6187,7 @@ SUITE(extraction) { RUN_TEST(swift_class); RUN_TEST(kotlin_function); RUN_TEST(kotlin_class); + RUN_TEST(kotlin_operator_and_convention_calls_emit_call_sites); RUN_TEST(scala_function); RUN_TEST(scala_class); RUN_TEST(dart_class); @@ -5118,13 +6196,18 @@ SUITE(extraction) { /* Systems */ RUN_TEST(rust_function); RUN_TEST(rust_struct); + RUN_TEST(rust_cfg_identity_preserves_predicates_and_call_scope); + RUN_TEST(rust_cfg_identity_retains_long_distinguishing_suffix); RUN_TEST(go_function); RUN_TEST(go_struct); RUN_TEST(go_interface); + RUN_TEST(dlang_struct); RUN_TEST(zig_function); RUN_TEST(c_function); + RUN_TEST(c_function_return_type_preserves_pointer_and_qualifier); RUN_TEST(c_struct); RUN_TEST(cpp_class); + RUN_TEST(cpp_method_return_type_preserves_pointer_and_qualifier); /* Scripting */ RUN_TEST(python_function); @@ -5143,11 +6226,15 @@ SUITE(extraction) { RUN_TEST(elixir_function); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); + RUN_TEST(ocaml_nested_let_call_attributed_to_outer_function); + RUN_TEST(purescript_exp_apply_call_edge); + RUN_TEST(agda_body_call_attributed_to_function); RUN_TEST(erlang_function); /* Markup/Config */ RUN_TEST(yaml_variables); RUN_TEST(hcl_blocks); + RUN_TEST(hcl_infra_bindings_retain_all_nested_targets); RUN_TEST(sql_create_table); RUN_TEST(dockerfile_stages); @@ -5161,8 +6248,17 @@ SUITE(extraction) { /* v0.5 expansion */ RUN_TEST(fsharp_function); RUN_TEST(julia_function); + RUN_TEST(julia_short_form_assignment_function); RUN_TEST(elm_function); RUN_TEST(nix_function); + RUN_TEST(jsonnet_function_call_edge); + RUN_TEST(typst_function_call_edge); + RUN_TEST(nickel_function_application_edge); + RUN_TEST(nickel_curried_call_beyond_8_wrappers); + RUN_TEST(func_function_application_edge); + RUN_TEST(vhdl_function_call_edge); + RUN_TEST(verilog_function_call_edge); + RUN_TEST(systemverilog_subroutine_call_edge); RUN_TEST(nix_defs_in_let_rooted_file); RUN_TEST(nix_defs_in_attrset_rooted_file); RUN_TEST(nix_defs_in_nested_let); @@ -5173,7 +6269,9 @@ SUITE(extraction) { RUN_TEST(nix_dotted_attrpath_qualifies_like_nested); RUN_TEST(nix_quoted_attr_name_strips_quotes); RUN_TEST(nix_interpolated_attr_mints_no_def); + RUN_TEST(nix_interpolated_attr_after_many_escapes_mints_no_def); RUN_TEST(nix_module_level_bindings_mint_variables); + RUN_TEST(nix_module_vars_survive_deep_curried_header); RUN_TEST(nix_nested_bindings_are_not_module_level); RUN_TEST(nix_lambda_binding_is_function_not_variable); RUN_TEST(nix_curried_lambda_mints_one_def); @@ -5187,14 +6285,19 @@ SUITE(extraction) { RUN_TEST(swift_chained_call); RUN_TEST(objc_interface); RUN_TEST(objc_implementation); + RUN_TEST(objc_method_call_attributed_to_method); RUN_TEST(dart_top_level_function); + RUN_TEST(dart_body_call_attributed_to_function); RUN_TEST(rust_enum); + RUN_TEST(rust_impl_call_attributed_to_method); RUN_TEST(zig_struct); RUN_TEST(cpp_function); + RUN_TEST(cpp_operator_and_implicit_calls_emit_call_sites); RUN_TEST(cpp_gtest_same_name_collision_issue1266); RUN_TEST(cpp_gtest_f_unique_name_issue1266); RUN_TEST(cpp_out_of_line_method_issue428); RUN_TEST(cobol_paragraph); + RUN_TEST(cobol_call_statement_edge); RUN_TEST(verilog_module); RUN_TEST(cuda_kernel); RUN_TEST(python_decorator); @@ -5209,9 +6312,12 @@ SUITE(extraction) { /* Config/Markup */ RUN_TEST(html_elements); RUN_TEST(sql_function); + RUN_TEST(sql_invocation_call_edge); RUN_TEST(meson_project); RUN_TEST(css_rules); + RUN_TEST(css_function_call_edge); RUN_TEST(scss_rules); + RUN_TEST(scss_function_call_edge); RUN_TEST(toml_basic); RUN_TEST(cmake_function); RUN_TEST(json_object); @@ -5239,6 +6345,7 @@ SUITE(extraction) { RUN_TEST(wolfram_call); RUN_TEST(wolfram_caller_attribution); RUN_TEST(c_caller_attribution); + RUN_TEST(c_call_retains_loop_depth_beyond_64_scopes); RUN_TEST(cpp_out_of_line_method_caller_attribution); RUN_TEST(cpp_out_of_line_ctor_dtor_caller_attribution); RUN_TEST(wolfram_parse); @@ -5257,6 +6364,12 @@ SUITE(extraction) { RUN_TEST(makefile_rule_as_function); RUN_TEST(makefile_multiple_targets); RUN_TEST(makefile_variable_extraction); + RUN_TEST(makefile_builtin_call_edges); + RUN_TEST(just_function_call_edge); + RUN_TEST(llvm_call_edge); + RUN_TEST(nasm_call_edge); + RUN_TEST(puppet_function_call_edge); + RUN_TEST(puppet_include_statement_call_edge); RUN_TEST(vimscript_function_extraction); RUN_TEST(vimscript_function_without_bang); RUN_TEST(julia_function_extraction); @@ -5264,6 +6377,7 @@ SUITE(extraction) { /* Cross-cutting */ RUN_TEST(python_calls); + RUN_TEST(python_resolvable_builtin_calls); RUN_TEST(python_iris_classMethodValue); RUN_TEST(go_calls); RUN_TEST(python_imports); @@ -5279,6 +6393,7 @@ SUITE(extraction) { RUN_TEST(svelte_imports_no_script); RUN_TEST(vue_imports_basic); RUN_TEST(html_imports_basic); + RUN_TEST(html_imports_retain_scripts_beyond_16_blocks); /* config_extraction_test.go ports */ RUN_TEST(toml_basic_table_and_pair); @@ -5313,14 +6428,24 @@ SUITE(extraction) { RUN_TEST(js_index_module_qn_not_collide_with_folder); RUN_TEST(python_regular_module_qn_unchanged); RUN_TEST(extract_java_method_annotations_issue382); + /* restored from merge base */ RUN_TEST(extract_java_jaxrs_path_composition_issue1005); + RUN_TEST(extract_javascript_channel_identifier_after_former_constant_limit); + RUN_TEST(extract_python_channel_identifier_after_former_constant_limit); + RUN_TEST(extract_cpp_functionlike_macro_type_arg_no_false_parse_partial_issue1071); + RUN_TEST(extract_cpp_real_in_body_error_still_flagged_issue1071); + RUN_TEST(extract_python_mock_patch_is_not_route); RUN_TEST(extract_ts_template_string_url_issue1006); RUN_TEST(extract_java_no_double_class_qn); RUN_TEST(extract_go_no_filename_in_module_qn); + RUN_TEST(extract_go_retains_all_multi_return_types); RUN_TEST(extract_large_ts_has_functions_issue213); /* Per-function complexity metrics (Tier A) */ RUN_TEST(complexity_nested_loops_depth); + RUN_TEST(complexity_retains_branches_beyond_4096_siblings); + RUN_TEST(ast_profile_retains_if_nodes_beyond_2048_frontier); + RUN_TEST(ast_profile_tracks_parameter_context); RUN_TEST(complexity_loop_with_branch); RUN_TEST(complexity_flat_no_loops); RUN_TEST(complexity_linear_scan_in_loop); diff --git a/tests/test_extraction_inheritance.c b/tests/test_extraction_inheritance.c index cd4e6fec2..483323c56 100644 --- a/tests/test_extraction_inheritance.c +++ b/tests/test_extraction_inheritance.c @@ -110,6 +110,28 @@ static int bases_count(CBMDefinition *d) { return n; } +static int assert_exact_bases(CBMDefinition *d, const char *class_name, + const char *const *expected) { + int actual_count = bases_count(d); + int expected_count = 0; + while (expected[expected_count]) { + expected_count++; + } + if (actual_count != expected_count) { + printf(" FAIL [%s] base_classes has %d entries, expected exactly %d\n", class_name, + actual_count, expected_count); + return 0; + } + for (int i = 0; i < expected_count; i++) { + if (strcmp(d->base_classes[i], expected[i]) != 0) { + printf(" FAIL [%s] base_classes[%d] is \"%s\", expected \"%s\"\n", class_name, i, + d->base_classes[i], expected[i]); + return 0; + } + } + return 1; +} + /* ── Table-driven case type ─────────────────────────────────────── */ /* Labels used to look up the class definition in the extraction result. @@ -1658,6 +1680,99 @@ TEST(inherit_rust_impls) { PASS(); } +/* + * Base-class lists are semantic data, not previews. Exercise independent + * generic and language-specific walkers above the former 15-entry local-array + * ceiling, requiring exact count and source order so tail loss, duplication, + * and reordering all fail automatically. + */ +TEST(inherit_wide_base_lists_are_exact) { + static const char *const expected[] = { + "Base01", "Base02", "Base03", "Base04", "Base05", "Base06", "Base07", + "Base08", "Base09", "Base10", "Base11", "Base12", "Base13", "Base14", + "Base15", "Base16", "Base17", "Base18", "Base19", "Base20", NULL, + }; + static const inherit_case_t cases[] = { + {CBM_LANG_JAVA, + "Wide.java", + "interface WideJava extends Base01, Base02, Base03, Base04, Base05, Base06, Base07, " + "Base08, Base09, Base10, Base11, Base12, Base13, Base14, Base15, Base16, Base17, " + "Base18, Base19, Base20 {}", + "WideJava", + {NULL}, + {NULL}, + 0}, + {CBM_LANG_CPP, + "wide.cpp", + "class WideCpp : public Base01, public Base02, public Base03, public Base04, public " + "Base05, public Base06, public Base07, public Base08, public Base09, public Base10, " + "public Base11, public Base12, public Base13, public Base14, public Base15, public " + "Base16, public Base17, public Base18, public Base19, public Base20 {};", + "WideCpp", + {NULL}, + {NULL}, + 0}, + {CBM_LANG_CSHARP, + "Wide.cs", + "interface WideCs : Base01, Base02, Base03, Base04, Base05, Base06, Base07, Base08, " + "Base09, Base10, Base11, Base12, Base13, Base14, Base15, Base16, Base17, Base18, " + "Base19, Base20 {}", + "WideCs", + {NULL}, + {NULL}, + 0}, + {CBM_LANG_TYPESCRIPT, + "wide.ts", + "interface WideTs extends Base01, Base02, Base03, Base04, Base05, Base06, Base07, " + "Base08, Base09, Base10, Base11, Base12, Base13, Base14, Base15, Base16, Base17, " + "Base18, Base19, Base20 {}", + "WideTs", + {NULL}, + {NULL}, + 0}, + {CBM_LANG_PHP, + "Wide.php", + "src, (int)strlen(tc->src), tc->lang, "t", tc->path, 0, NULL, NULL); + if (!r) { + printf(" FAIL [%s] cbm_extract_file returned NULL\n", tc->class_name); + return 1; + } + CBMDefinition *def = find_def_flex(r, tc->class_name); + if (!def) { + printf(" FAIL [%s] definition not found in extraction result\n", tc->class_name); + cbm_free_result(r); + return 1; + } + int exact = assert_exact_bases(def, tc->class_name, expected); + cbm_free_result(r); + if (!exact) { + return 1; + } + } + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * SUITE declaration * ═══════════════════════════════════════════════════════════════════ */ @@ -1668,6 +1783,7 @@ SUITE(extraction_inheritance) { RUN_TEST(inherit_csharp); RUN_TEST(inherit_cpp); RUN_TEST(inherit_rust_impls); + RUN_TEST(inherit_wide_base_lists_are_exact); /* Languages expected RED (broken extractors — reproduce-first) */ RUN_TEST(inherit_python); /* RED: identifier-node not matched in collect_bases_from_field */ diff --git a/tests/test_foundation_main.c b/tests/test_foundation_main.c new file mode 100644 index 000000000..c69c11ed6 --- /dev/null +++ b/tests/test_foundation_main.c @@ -0,0 +1,32 @@ +#include "test_framework.h" +#include "foundation/profile.h" + +int tf_pass_count = 0; +int tf_fail_count = 0; +int tf_skip_count = 0; +int tf_filter_count = 0; + +extern void suite_arena(void); +extern void suite_hash_table(void); +extern void suite_dyn_array(void); +extern void suite_str_intern(void); +extern void suite_log(void); +extern void suite_str_util(void); +extern void suite_platform(void); +extern void suite_dump_verify(void); +extern void suite_subprocess(void); + +int main(void) { + cbm_profile_init(); + printf("\n codebase-memory-mcp C foundation test suite\n"); + RUN_SUITE(arena); + RUN_SUITE(hash_table); + RUN_SUITE(dyn_array); + RUN_SUITE(str_intern); + RUN_SUITE(log); + RUN_SUITE(str_util); + RUN_SUITE(platform); + RUN_SUITE(dump_verify); + RUN_SUITE(subprocess); + TEST_SUMMARY(); +} diff --git a/tests/test_fqn.c b/tests/test_fqn.c index d2163424f..0103f97f5 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -21,6 +21,57 @@ free(_r); \ } while (0) +enum { FQN_DEEP_SEGMENT_COUNT = 300, FQN_LONG_RELATIVE_FILL = 1100 }; + +static char *fqn_deep_path(const char *tail) { + size_t tail_len = strlen(tail); + size_t path_len = (size_t)FQN_DEEP_SEGMENT_COUNT * 2U + tail_len; + char *path = malloc(path_len + 1U); + if (!path) { + return NULL; + } + char *p = path; + for (int i = 0; i < FQN_DEEP_SEGMENT_COUNT; i++) { + *p++ = 'a'; + *p++ = '/'; + } + memcpy(p, tail, tail_len + 1U); + return path; +} + +static char *fqn_expected_from_path(const char *path, const char *symbol, bool strip_extension) { + const char prefix[] = "proj."; + size_t path_len = strlen(path); + size_t symbol_len = symbol ? strlen(symbol) : 0U; + char *expected = malloc(sizeof(prefix) + path_len + symbol_len + 1U); + if (!expected) { + return NULL; + } + char *p = expected; + memcpy(p, prefix, sizeof(prefix) - 1U); + p += sizeof(prefix) - 1U; + memcpy(p, path, path_len + 1U); + for (char *c = p; *c; c++) { + if (*c == '/') { + *c = '.'; + } + } + if (strip_extension) { + char *extension = strrchr(p, '.'); + if (!extension) { + free(expected); + return NULL; + } + *extension = '\0'; + } + p += strlen(p); + if (symbol_len > 0U) { + *p++ = '.'; + memcpy(p, symbol, symbol_len + 1U); + } + return expected; +} + /* ================================================================ * cbm_pipeline_fqn_compute * ================================================================ */ @@ -107,6 +158,21 @@ TEST(fqn_compute_nested_deep) { PASS(); } +TEST(fqn_compute_retains_every_deep_path_segment) { + char *path = fqn_deep_path("tail.go"); + ASSERT_NOT_NULL(path); + char *expected = fqn_expected_from_path(path, "Symbol", true); + ASSERT_NOT_NULL(expected); + char *actual = cbm_pipeline_fqn_compute("proj", path, "Symbol"); + ASSERT_NOT_NULL(actual); + ASSERT_STR_EQ(actual, expected); + ASSERT_NOT_NULL(strstr(actual, ".tail.Symbol")); + free(actual); + free(expected); + free(path); + PASS(); +} + /* ── Python __init__.py ───────────────────────────────────────── */ TEST(fqn_compute_init_py_with_name) { @@ -253,6 +319,16 @@ TEST(fqn_compute_spec_ext) { PASS(); } +TEST(fqn_compute_file_nodes_keep_extension_identity) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src/ui/http_server.c", "__file__"), + "proj.src.ui.http_server.c.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "src/ui/http_server.h", "__file__"), + "proj.src.ui.http_server.h.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "pkg/__init__.py", "__file__"), + "proj.pkg.__init__.py.__file__"); + PASS(); +} + /* ── Leading / trailing slashes ───────────────────────────────── */ TEST(fqn_compute_leading_slash) { @@ -346,6 +422,83 @@ TEST(fqn_module_deep) { PASS(); } +TEST(fqn_relative_import_preserves_language_forms) { + struct { + const char *source; + const char *module; + const char *expected; + } cases[] = { + {"src/features/consumer.ts", "./nested/util.js", "src/features/nested/util"}, + {"src/features/consumer.ts", "../shared/helpers.ts", "src/shared/helpers"}, + {"src/features/consumer.ts", "./foo.test.ts", "src/features/foo.test"}, + {"src/features/consumer.ts", "./.hidden", "src/features/.hidden"}, + {"src/features/consumer.py", ".helpers", "src/features/helpers"}, + {"src/features/consumer.py", "..shared.helpers", "src/shared/helpers"}, + {"src/features/consumer.py", "...shared", "shared"}, + {"src/features/consumer.py", ".", "src/features"}, + {"src/features/consumer.py", "..", "src"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char *actual = cbm_pipeline_resolve_relative_import(cases[i].source, cases[i].module); + ASSERT_NOT_NULL(actual); + ASSERT_STR_EQ(actual, cases[i].expected); + free(actual); + } + PASS(); +} + +/* Relative-import resolution must preserve both a long importing directory + * and a long imported segment. The former 1,024-byte staging buffer either + * dropped the importer's directory suffix or returned NULL before the caller + * could construct the module QN. This logical-path test is intentionally + * independent of any host filesystem component limit. */ +TEST(fqn_relative_import_preserves_exact_long_paths) { + char *fill = malloc((size_t)FQN_LONG_RELATIVE_FILL + 1U); + ASSERT_NOT_NULL(fill); + memset(fill, 'd', FQN_LONG_RELATIVE_FILL); + fill[FQN_LONG_RELATIVE_FILL] = '\0'; + + size_t source_size = strlen("root//consumer.ts") + strlen(fill) + 1U; + size_t expected_source_size = strlen("root//helpers") + strlen(fill) + 1U; + size_t module_size = strlen("./.ts") + strlen(fill) + 1U; + size_t expected_module_size = strlen("src/") + strlen(fill) + 1U; + char *source = malloc(source_size); + char *expected_source = malloc(expected_source_size); + char *module = malloc(module_size); + char *expected_module = malloc(expected_module_size); + if (!source || !expected_source || !module || !expected_module) { + free(expected_module); + free(module); + free(expected_source); + free(source); + free(fill); + FAIL("long relative-import fixture allocation"); + } + snprintf(source, source_size, "root/%s/consumer.ts", fill); + snprintf(expected_source, expected_source_size, "root/%s/helpers", fill); + snprintf(module, module_size, "./%s.ts", fill); + snprintf(expected_module, expected_module_size, "src/%s", fill); + + char *from_long_source = cbm_pipeline_resolve_relative_import(source, "./helpers.ts"); + char *from_long_js_module = cbm_pipeline_resolve_relative_import("src/consumer.ts", module); + snprintf(module, module_size, ".%s", fill); + char *from_long_python_module = cbm_pipeline_resolve_relative_import("src/consumer.py", module); + bool exact = from_long_source && strcmp(from_long_source, expected_source) == 0 && + from_long_js_module && strcmp(from_long_js_module, expected_module) == 0 && + from_long_python_module && strcmp(from_long_python_module, expected_module) == 0; + + free(from_long_python_module); + free(from_long_js_module); + free(from_long_source); + free(expected_module); + free(module); + free(expected_source); + free(source); + free(fill); + ASSERT_TRUE(exact); + PASS(); +} + /* ================================================================ * cbm_pipeline_fqn_folder * ================================================================ */ @@ -400,6 +553,41 @@ TEST(fqn_folder_double_slash) { PASS(); } +TEST(fqn_folder_retains_every_deep_path_segment) { + char *path = fqn_deep_path("tail"); + ASSERT_NOT_NULL(path); + char *expected = fqn_expected_from_path(path, NULL, false); + ASSERT_NOT_NULL(expected); + char *actual = cbm_pipeline_fqn_folder("proj", path); + ASSERT_NOT_NULL(actual); + ASSERT_STR_EQ(actual, expected); + ASSERT_NOT_NULL(strstr(actual, ".tail")); + free(actual); + free(expected); + free(path); + PASS(); +} + +TEST(fqn_without_project_exact_prefix) { + ASSERT_STR_EQ(cbm_pipeline_fqn_without_project("tmp-a.b", "tmp-a.b.pkg.worker.run"), + "pkg.worker.run"); + PASS(); +} + +TEST(fqn_without_project_rejects_partial_prefix) { + const char *qn = "tmp-a.bc.pkg.worker.run"; + ASSERT_TRUE(cbm_pipeline_fqn_without_project("tmp-a.b", qn) == qn); + PASS(); +} + +TEST(fqn_without_project_preserves_project_node_and_nulls) { + const char *project = "tmp-a.b"; + ASSERT_TRUE(cbm_pipeline_fqn_without_project(project, project) == project); + ASSERT_TRUE(cbm_pipeline_fqn_without_project(NULL, project) == project); + ASSERT_TRUE(cbm_pipeline_fqn_without_project(project, NULL) == NULL); + PASS(); +} + /* ================================================================ * cbm_project_name_from_path * ================================================================ */ @@ -600,6 +788,7 @@ SUITE(fqn) { RUN_TEST(fqn_compute_nested_two_levels); RUN_TEST(fqn_compute_nested_three_levels); RUN_TEST(fqn_compute_nested_deep); + RUN_TEST(fqn_compute_retains_every_deep_path_segment); /* fqn_compute: Python __init__.py */ RUN_TEST(fqn_compute_init_py_with_name); @@ -636,6 +825,7 @@ SUITE(fqn) { /* fqn_compute: multiple extensions */ RUN_TEST(fqn_compute_double_ext); RUN_TEST(fqn_compute_spec_ext); + RUN_TEST(fqn_compute_file_nodes_keep_extension_identity); /* fqn_compute: leading / trailing slashes */ RUN_TEST(fqn_compute_leading_slash); @@ -657,6 +847,8 @@ SUITE(fqn) { RUN_TEST(fqn_module_null_path); RUN_TEST(fqn_module_null_project); RUN_TEST(fqn_module_deep); + RUN_TEST(fqn_relative_import_preserves_language_forms); + RUN_TEST(fqn_relative_import_preserves_exact_long_paths); /* fqn_folder */ RUN_TEST(fqn_folder_basic); @@ -669,6 +861,10 @@ SUITE(fqn) { RUN_TEST(fqn_folder_trailing_slash); RUN_TEST(fqn_folder_leading_slash); RUN_TEST(fqn_folder_double_slash); + RUN_TEST(fqn_folder_retains_every_deep_path_segment); + RUN_TEST(fqn_without_project_exact_prefix); + RUN_TEST(fqn_without_project_rejects_partial_prefix); + RUN_TEST(fqn_without_project_preserves_project_node_and_nulls); /* project_name_from_path */ RUN_TEST(project_name_unix_path); diff --git a/tests/test_framework.h b/tests/test_framework.h index 2af654b4d..ca6953856 100644 --- a/tests/test_framework.h +++ b/tests/test_framework.h @@ -32,11 +32,37 @@ #include #include +#ifndef __has_feature +#define __has_feature(x) 0 +#endif + +#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__) || \ + __has_feature(address_sanitizer) || __has_feature(thread_sanitizer) +#define TF_SANITIZER_ACTIVE 1 +#else +#define TF_SANITIZER_ACTIVE 0 +#endif + +/* Resolve the on-disk cache dir — honors the CBM_CACHE_DIR env var (used by the + * test runner to isolate each run into a per-run temp dir) and otherwise falls + * back to ~/.cache/codebase-memory-mcp. Defined in foundation/platform.c. + * Forward-declared here so every test file builds the SAME db path the pipeline + * writes (the pipeline honors CBM_CACHE_DIR); hardcoding ~/.cache mismatched the + * write path and yielded empty-store failures under isolation. */ +const char *cbm_resolve_cache_dir(void); + /* ── Global counters (defined in test_main.c) ──────────────────── */ extern int tf_pass_count; extern int tf_fail_count; extern int tf_skip_count; +extern int tf_filter_count; + +/* Canonical repository root captured before any suite can change process CWD. + * Returns NULL when the runner image is not inside a source checkout. */ +const char *tf_repository_root(void); + +#define TF_ONLY_TEST_ENV "CBM_ONLY_TEST" /* ── Color helpers ─────────────────────────────────────────────── */ @@ -222,8 +248,17 @@ static inline const char *tf_reset(void) { /* ── Test runner ───────────────────────────────────────────────── */ +static inline int tf_test_filter_matches(const char *name) { + const char *only_test = getenv(TF_ONLY_TEST_ENV); + return !only_test || only_test[0] == '\0' || strstr(name, only_test) != NULL; +} + #define RUN_TEST(name) \ do { \ + if (!tf_test_filter_matches(#name)) { \ + tf_filter_count++; \ + break; \ + } \ printf(" %-55s", #name); \ fflush(stdout); \ int _result = test_##name(); \ @@ -241,12 +276,19 @@ static inline const char *tf_reset(void) { #define SUITE(name) void suite_##name(void) -#define RUN_SUITE(name) \ +/* TF_RUN_SUITE_RAW is the unconditional runner. RUN_SUITE is its public + * spelling. They are separate names on purpose: test_main.c poisons RUN_SUITE + * inside its full-run path so a suite that runs without being listed by + * --list-suites is a compile error, and drives that path through + * TF_RUN_SUITE_RAW, which stays available. */ +#define TF_RUN_SUITE_RAW(name) \ do { \ printf("\n%s=== %s ===%s\n", tf_dim(), #name, tf_reset()); \ suite_##name(); \ } while (0) +#define RUN_SUITE(name) TF_RUN_SUITE_RAW(name) + /* ── Summary ───────────────────────────────────────────────────── */ #define TEST_SUMMARY() \ @@ -257,7 +299,13 @@ static inline const char *tf_reset(void) { printf(", %s%d failed%s", tf_red(), tf_fail_count, tf_reset()); \ if (tf_skip_count > 0) \ printf(", %s%d skipped%s", tf_dim(), tf_skip_count, tf_reset()); \ + if (tf_filter_count > 0) \ + printf(", %s%d filtered%s", tf_dim(), tf_filter_count, \ + tf_reset()); \ printf("\n────────────────────────────────────────────\n\n"); \ + if (getenv(TF_ONLY_TEST_ENV) && getenv(TF_ONLY_TEST_ENV)[0] && \ + tf_pass_count == 0 && tf_fail_count == 0 && tf_skip_count == 0) \ + return 1; \ return tf_fail_count > 0 ? 1 : 0; \ } while (0) diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..fedc7cca9 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -25,7 +25,9 @@ */ #include "test_framework.h" #include "test_helpers.h" +#include "git/git_command.h" #include "git/git_context.h" +#include "git/git_snapshot.h" #include #include @@ -61,6 +63,27 @@ static int make_git_repo(const char *dir) { } #endif /* _WIN32 */ +/* Cross-platform setup for branch-only tests. Unlike git_run(), this uses the + * production command formatter and cbm_popen/cbm_pclose lifecycle on Windows + * as well as POSIX. */ +static int make_git_repo_portable(const char *dir) { + if (th_mkdir_p(dir) != 0) return -1; + const char *const init_args[] = {"init", "-q", NULL}; + const char *const email_args[] = {"config", "user.email", "test@example.com", NULL}; + const char *const name_args[] = {"config", "user.name", "Test", NULL}; + if (cbm_git_drain_command(dir, init_args) != 0) return -1; + if (cbm_git_drain_command(dir, email_args) != 0) return -1; + if (cbm_git_drain_command(dir, name_args) != 0) return -1; + char path[CBM_SZ_1K]; + int n = snprintf(path, sizeof(path), "%s/.keep", dir); + if (n <= 0 || (size_t)n >= sizeof(path)) return -1; + th_write_file(path, ""); + const char *const add_args[] = {"add", ".keep", NULL}; + const char *const commit_args[] = {"commit", "-q", "-m", "init", NULL}; + if (cbm_git_drain_command(dir, add_args) != 0) return -1; + return cbm_git_drain_command(dir, commit_args); +} + /* ── canonical_root: normal repo indexed from its root ──────────── */ TEST(canonical_root_repo_root) { @@ -232,10 +255,135 @@ TEST(canonical_root_linked_worktree) { #endif /* _WIN32 */ } +TEST(current_branch_resolves_attached_detached_unborn_and_non_git) { + char repo[256]; + char *raw = th_mktempdir("cbm_branch_repo"); + if (!raw) FAIL("th_mktempdir returned NULL"); + snprintf(repo, sizeof(repo), "%s", raw); + + char non_git[256]; + raw = th_mktempdir("cbm_branch_plain"); + if (!raw) { + th_rmtree(repo); + FAIL("th_mktempdir returned NULL"); + } + snprintf(non_git, sizeof(non_git), "%s", raw); + + const char *const branch_args[] = {"checkout", "-q", "-b", "branch-probe", NULL}; + bool setup_ok = + make_git_repo_portable(repo) == 0 && cbm_git_drain_command(repo, branch_args) == 0; + char *attached = NULL; + char *detached = NULL; + char *unborn = NULL; + char *plain = NULL; + int attached_rc = setup_ok ? cbm_git_current_branch(repo, &attached) : CBM_NOT_FOUND; + const char *const detach_args[] = {"checkout", "-q", "--detach", NULL}; + bool detach_ok = setup_ok && cbm_git_drain_command(repo, detach_args) == 0; + int detached_rc = detach_ok ? cbm_git_current_branch(repo, &detached) : CBM_NOT_FOUND; + cbm_git_context_t detached_context = {0}; + int detached_context_rc = + detach_ok ? cbm_git_context_resolve(repo, &detached_context) : CBM_NOT_FOUND; + const char *const unborn_init_args[] = {"init", "-q", NULL}; + const char *const unborn_ref_args[] = { + "symbolic-ref", "HEAD", "refs/heads/unborn-probe", NULL}; + bool unborn_setup_ok = cbm_git_drain_command(non_git, unborn_init_args) == 0 && + cbm_git_drain_command(non_git, unborn_ref_args) == 0; + int unborn_rc = + unborn_setup_ok ? cbm_git_current_branch(non_git, &unborn) : CBM_NOT_FOUND; + cbm_git_context_t unborn_context = {0}; + int unborn_context_rc = + unborn_setup_ok ? cbm_git_context_resolve(non_git, &unborn_context) : CBM_NOT_FOUND; + char plain_dir[256]; + raw = th_mktempdir("cbm_branch_plain_after_unborn"); + bool plain_setup_ok = raw != NULL; + snprintf(plain_dir, sizeof(plain_dir), "%s", raw ? raw : ""); + int plain_rc = plain_setup_ok ? cbm_git_current_branch(plain_dir, &plain) : CBM_NOT_FOUND; + + bool attached_ok = attached_rc == 0 && attached && strcmp(attached, "branch-probe") == 0; + bool detached_ok = detached_rc == 0 && detached && strcmp(detached, "DETACHED") == 0; + bool detached_context_ok = detached_context_rc == 0 && detached_context.is_detached && + detached_context.branch && + strcmp(detached_context.branch, "DETACHED") == 0; + bool unborn_ok = unborn_rc == 0 && unborn && strcmp(unborn, "unborn-probe") == 0; + bool unborn_context_ok = unborn_context_rc == 0 && unborn_context.is_git && + !unborn_context.is_detached && unborn_context.branch && + strcmp(unborn_context.branch, "unborn-probe") == 0 && + unborn_context.head_sha && unborn_context.head_sha[0] == '\0' && + unborn_context.base_sha && unborn_context.base_sha[0] == '\0'; + bool plain_ok = plain_rc == CBM_NOT_FOUND && plain == NULL; + free(attached); + free(detached); + free(unborn); + free(plain); + cbm_git_context_free(&detached_context); + cbm_git_context_free(&unborn_context); + if (plain_setup_ok) th_rmtree(plain_dir); + th_rmtree(non_git); + th_rmtree(repo); + + ASSERT_TRUE(setup_ok); + ASSERT_TRUE(detach_ok); + ASSERT_TRUE(unborn_setup_ok); + ASSERT_TRUE(plain_setup_ok); + ASSERT_TRUE(attached_ok); + ASSERT_TRUE(detached_ok); + ASSERT_TRUE(detached_context_ok); + ASSERT_TRUE(unborn_ok); + ASSERT_TRUE(unborn_context_ok); + ASSERT_TRUE(plain_ok); + PASS(); +} + +/* Shell command strings either reject or reinterpret these characters, + * especially through cmd.exe. The shared Git runner must pass the repository + * path as one literal argv element on every platform. This exercises command + * setup, context resolution, and snapshot capture through the real Git binary. */ +TEST(literal_metacharacter_repo_path_round_trips_through_git_argv) { + char base[CBM_PATH_MAX]; + char *raw = th_mktempdir("cbm_git_argv_literal"); + if (!raw) FAIL("th_mktempdir returned NULL"); + int base_written = snprintf(base, sizeof(base), "%s", raw); + if (base_written <= 0 || (size_t)base_written >= sizeof(base)) { + FAIL("temporary base path does not fit"); + } + + char repo[CBM_PATH_MAX]; + int repo_written = snprintf(repo, sizeof(repo), "%s/repo %%!^&; literal", base); + if (repo_written <= 0 || (size_t)repo_written >= sizeof(repo)) { + th_rmtree(base); + FAIL("literal repository path does not fit"); + } + if (make_git_repo_portable(repo) != 0) { + th_rmtree(base); + SKIP_PLATFORM("git not available to initialize literal-path repository"); + } + + cbm_git_context_t context = {0}; + cbm_git_snapshot_t snapshot = {0}; + int context_rc = cbm_git_context_resolve(repo, &context); + int snapshot_rc = cbm_git_snapshot_read( + repo, CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_DIRTY | CBM_GIT_SNAPSHOT_FILE_COUNT, + &snapshot); + bool context_ok = context_rc == 0 && context.is_git && context.worktree_root && + strstr(context.worktree_root, "repo %!^&; literal") != NULL; + bool snapshot_ok = snapshot_rc == 0 && snapshot.path_supported && snapshot.is_git && + snapshot.head[0] != '\0' && snapshot.file_count == 1; + + cbm_git_context_free(&context); + th_rmtree(base); + + ASSERT_TRUE(cbm_git_validate_repo_path(repo)); + ASSERT_TRUE(context_ok); + ASSERT_TRUE(snapshot_ok); + PASS(); +} + /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); + RUN_TEST(current_branch_resolves_attached_detached_unborn_and_non_git); + RUN_TEST(literal_metacharacter_repo_path_round_trips_through_git_argv); } diff --git a/tests/test_grammar_imports.c b/tests/test_grammar_imports.c index df2b840fd..32ffcee9b 100644 --- a/tests/test_grammar_imports.c +++ b/tests/test_grammar_imports.c @@ -140,8 +140,8 @@ TEST(grammar_imports_extracted) { failures++; } } - fprintf(stderr, " [IMPORTS] %d import-capable grammars: %d FAILURES (each = a grammar whose " - "imports are not extracted)\n", + fprintf(stderr, " [IMPORTS] %d import-capable grammars checked; observed gaps=%d " + "(gap = imports not extracted)\n", n, failures); ASSERT_EQ(failures, 0); PASS(); diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index bae856fab..3934b0f21 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -345,7 +345,7 @@ TEST(grammar_code_extracts_defs) { failures++; } } - fprintf(stderr, " [CODE-DEFS] %d code/IDL grammars: %d under-extraction FAILURES\n", + fprintf(stderr, " [CODE-DEFS] %d code/IDL grammars checked; observed gaps=%d\n", (int)(sizeof(MUST_EXTRACT_DEFS) / sizeof(MUST_EXTRACT_DEFS[0])) - 1, failures); ASSERT_EQ(failures, 0); PASS(); diff --git a/tests/test_grammar_probe_a.c b/tests/test_grammar_probe_a.c index 268c4cc0e..53c797815 100644 --- a/tests/test_grammar_probe_a.c +++ b/tests/test_grammar_probe_a.c @@ -76,7 +76,9 @@ static cbm_store_t *gpa_open_indexed(GpaProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_b.c b/tests/test_grammar_probe_b.c index 2e2577127..433aff8c0 100644 --- a/tests/test_grammar_probe_b.c +++ b/tests/test_grammar_probe_b.c @@ -72,7 +72,9 @@ static cbm_store_t *pb_open_indexed(ProbeLangProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_c.c b/tests/test_grammar_probe_c.c index ba8b2b14e..0136bcffb 100644 --- a/tests/test_grammar_probe_c.c +++ b/tests/test_grammar_probe_c.c @@ -60,7 +60,9 @@ static cbm_store_t *gp_open_indexed(GP_Proj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_d.c b/tests/test_grammar_probe_d.c index 717cfbe93..eb8ace0ea 100644 --- a/tests/test_grammar_probe_d.c +++ b/tests/test_grammar_probe_d.c @@ -74,7 +74,9 @@ static cbm_store_t *gpd_open_indexed(GpdProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_e.c b/tests/test_grammar_probe_e.c index 9a8c45769..a9c3fcfe6 100644 --- a/tests/test_grammar_probe_e.c +++ b/tests/test_grammar_probe_e.c @@ -81,7 +81,9 @@ static cbm_store_t *gpe_open_indexed(GpeProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index ed734f052..776fff5d4 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -70,7 +70,9 @@ static cbm_store_t *gpf_open_indexed(GpfProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_grammar_probe_g.c b/tests/test_grammar_probe_g.c index 3a95612a2..1c85dd7c9 100644 --- a/tests/test_grammar_probe_g.c +++ b/tests/test_grammar_probe_g.c @@ -83,7 +83,9 @@ static cbm_store_t *gpg_open_indexed(GpgProj *lp) { if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_graph_buffer.c b/tests/test_graph_buffer.c index 09a47af40..15d5390cc 100644 --- a/tests/test_graph_buffer.c +++ b/tests/test_graph_buffer.c @@ -7,7 +7,36 @@ #include "test_framework.h" #include "graph_buffer/graph_buffer.h" #include "store/store.h" +#include "foundation/compat.h" /* cbm_mkstemp */ +#include "foundation/compat_fs.h" +#include "foundation/constants.h" +#include "foundation/platform.h" +#include "sqlite3.h" /* vendored/sqlite3/ via -Ivendored/sqlite3 */ +#include #include +#include + +static int gbuf_make_temp_db(char *path, size_t pathsz) { + snprintf(path, pathsz, "/tmp/cbm_gbuf_dump_XXXXXX"); + int fd = cbm_mkstemp(path); + if (fd < 0) { + return -1; + } + close(fd); + return 0; +} + +static int gbuf_store_has_qn(const char *path, const char *project, const char *qn) { + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return 0; + } + cbm_node_t node = {0}; + int found = cbm_store_find_node_by_qn(store, project, qn, &node) == CBM_STORE_OK; + cbm_node_free_fields(&node); + cbm_store_close(store); + return found; +} /* ── Node operations ───────────────────────────────────────────── */ @@ -63,6 +92,141 @@ TEST(gbuf_upsert_updates) { PASS(); } +TEST(gbuf_route_upsert_file_path_is_deterministic) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + const char *route_qn = "__route__GET__/items/{}"; + int64_t id1 = cbm_gbuf_upsert_node(gb, "Route", "/items/{item_id}", route_qn, "z/last.py", 0, + 0, "{}"); + int64_t id2 = cbm_gbuf_upsert_node(gb, "Route", "/items/{id}", route_qn, "a/first.py", 0, 0, + "{\"method\":\"GET\",\"source\":\"decorator\"}"); + int64_t id3 = cbm_gbuf_upsert_node(gb, "Route", "", route_qn, "", 0, 0, "{}"); + ASSERT_EQ(id1, id2); + ASSERT_EQ(id1, id3); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, route_qn); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->name, "/items/{id}"); + ASSERT_STR_EQ(n->file_path, "a/first.py"); + ASSERT_STR_EQ(n->properties_json, "{\"method\":\"GET\",\"source\":\"decorator\"}"); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_section_upsert_file_path_is_deterministic) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + const char *section_qn = "proj.docs.deployment.Implantacao"; + + int64_t id1 = cbm_gbuf_upsert_node(gb, "Section", "Implantacao", section_qn, + "docs/deployment/index.md", 1, 2, "{}"); + int64_t id2 = cbm_gbuf_upsert_node(gb, "Section", "Implantacao", section_qn, + "docs/deployment.md", 1, 2, "{}"); + ASSERT_EQ(id1, id2); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, section_qn); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->file_path, "docs/deployment.md"); + + cbm_gbuf_free(gb); + PASS(); +} + +static int assert_full_definition_source(const cbm_gbuf_t *gb, const char *qn) { + enum { + FULL_DEF_START_LINE = 34, + FULL_DEF_END_LINE = 43, + }; + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, qn); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->file_path, "src/type.c"); + ASSERT_EQ(n->start_line, FULL_DEF_START_LINE); + ASSERT_EQ(n->end_line, FULL_DEF_END_LINE); + ASSERT_STR_EQ(n->properties_json, "{\"source\":\"definition\"}"); + return 0; +} + +static int assert_install_sh_module_source(const cbm_gbuf_t *gb, const char *qn) { + enum { + INSTALL_SH_START_LINE = 1, + INSTALL_SH_END_LINE = 221, + }; + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(gb, qn); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->label, "Module"); + ASSERT_STR_EQ(n->file_path, "install.sh"); + ASSERT_EQ(n->start_line, INSTALL_SH_START_LINE); + ASSERT_EQ(n->end_line, INSTALL_SH_END_LINE); + ASSERT_STR_EQ(n->properties_json, "{\"source\":\"shell\"}"); + return 0; +} + +TEST(gbuf_upsert_definition_source_prefers_richer_span) { + enum { + DECL_START_LINE = 7, + DECL_END_LINE = 7, + FULL_DEF_START_LINE = 34, + FULL_DEF_END_LINE = 43, + }; + const char *qn = "proj.TypeName"; + + cbm_gbuf_t *decl_then_def = cbm_gbuf_new("test", "/tmp"); + int64_t id1 = + cbm_gbuf_upsert_node(decl_then_def, "Class", "TypeName", qn, "include/type.h", + DECL_START_LINE, DECL_END_LINE, "{\"source\":\"declaration\"}"); + int64_t id2 = + cbm_gbuf_upsert_node(decl_then_def, "Class", "TypeName", qn, "src/type.c", + FULL_DEF_START_LINE, FULL_DEF_END_LINE, + "{\"source\":\"definition\"}"); + ASSERT_EQ(id1, id2); + ASSERT_EQ(assert_full_definition_source(decl_then_def, qn), 0); + cbm_gbuf_free(decl_then_def); + + cbm_gbuf_t *def_then_decl = cbm_gbuf_new("test", "/tmp"); + id1 = cbm_gbuf_upsert_node(def_then_decl, "Class", "TypeName", qn, "src/type.c", + FULL_DEF_START_LINE, FULL_DEF_END_LINE, + "{\"source\":\"definition\"}"); + id2 = cbm_gbuf_upsert_node(def_then_decl, "Class", "TypeName", qn, "include/type.h", + DECL_START_LINE, DECL_END_LINE, "{\"source\":\"declaration\"}"); + ASSERT_EQ(id1, id2); + ASSERT_EQ(assert_full_definition_source(def_then_decl, qn), 0); + cbm_gbuf_free(def_then_decl); + + PASS(); +} + +TEST(gbuf_upsert_module_source_prefers_richer_span) { + enum { + INSTALL_START_LINE = 1, + INSTALL_PS_END_LINE = 155, + INSTALL_SH_END_LINE = 221, + }; + const char *qn = "proj.install"; + + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t id1 = + cbm_gbuf_upsert_node(gb, "Module", "install.ps1", qn, "install.ps1", + INSTALL_START_LINE, INSTALL_PS_END_LINE, "{\"source\":\"powershell\"}"); + int64_t id2 = + cbm_gbuf_upsert_node(gb, "Module", "install.sh", qn, "install.sh", INSTALL_START_LINE, + INSTALL_SH_END_LINE, "{\"source\":\"shell\"}"); + ASSERT_EQ(id1, id2); + ASSERT_EQ(assert_install_sh_module_source(gb, qn), 0); + cbm_gbuf_free(gb); + + gb = cbm_gbuf_new("test", "/tmp"); + id1 = cbm_gbuf_upsert_node(gb, "Module", "install.sh", qn, "install.sh", + INSTALL_START_LINE, INSTALL_SH_END_LINE, + "{\"source\":\"shell\"}"); + id2 = cbm_gbuf_upsert_node(gb, "Module", "install.ps1", qn, "install.ps1", + INSTALL_START_LINE, INSTALL_PS_END_LINE, + "{\"source\":\"powershell\"}"); + ASSERT_EQ(id1, id2); + ASSERT_EQ(assert_install_sh_module_source(gb, qn), 0); + cbm_gbuf_free(gb); + + PASS(); +} + TEST(gbuf_find_by_id) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); int64_t id = cbm_gbuf_upsert_node(gb, "Function", "foo", "pkg.foo", "foo.go", 1, 5, "{}"); @@ -102,6 +266,40 @@ TEST(gbuf_find_by_label) { PASS(); } +TEST(gbuf_upsert_reindexes_label_and_name) { + enum { + COMPAT_DECL_START = 41, + COMPAT_DECL_END = 42, + COMPAT_DEF_START = 22, + COMPAT_DEF_END = 36, + }; + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t id1 = cbm_gbuf_upsert_node(gb, "Macro", "OLD_NAME", "pkg.compat.cbm_strndup", + "compat.h", COMPAT_DECL_START, COMPAT_DECL_END, "{}"); + int64_t id2 = cbm_gbuf_upsert_node(gb, "Function", "cbm_strndup", + "pkg.compat.cbm_strndup", "compat.c", COMPAT_DEF_START, + COMPAT_DEF_END, + "{\"loop_depth\":1,\"self_recursive\":false}"); + ASSERT_EQ(id1, id2); + + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Macro", &nodes, &count), 0); + ASSERT_EQ(count, 0); + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Function", &nodes, &count), 0); + ASSERT_EQ(count, 1); + ASSERT_EQ(nodes[0]->id, id1); + + ASSERT_EQ(cbm_gbuf_find_by_name(gb, "OLD_NAME", &nodes, &count), 0); + ASSERT_EQ(count, 0); + ASSERT_EQ(cbm_gbuf_find_by_name(gb, "cbm_strndup", &nodes, &count), 0); + ASSERT_EQ(count, 1); + ASSERT_EQ(nodes[0]->id, id1); + + cbm_gbuf_free(gb); + PASS(); +} + TEST(gbuf_find_by_name) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); cbm_gbuf_upsert_node(gb, "Function", "main", "a.main", "a.go", 1, 5, "{}"); @@ -128,7 +326,7 @@ TEST(gbuf_delete_by_label) { ASSERT_EQ(cbm_gbuf_edge_count(gb), 1); /* Delete all functions — should cascade-delete the CALLS edge */ - cbm_gbuf_delete_by_label(gb, "Function"); + ASSERT_EQ(cbm_gbuf_delete_by_label(gb, "Function"), 0); ASSERT_EQ(cbm_gbuf_node_count(gb), 1); /* only Class remains */ ASSERT_EQ(cbm_gbuf_edge_count(gb), 0); /* edge cascade-deleted */ @@ -211,6 +409,43 @@ TEST(gbuf_imports_multi_symbol_dedup) { PASS(); } +/* Incremental indexing deletes and recreates every node owned by a changed + * file. Both sibling imports must remain insertable after the shared target + * is cascade-deleted; a stale dedup key would silently discard one symbol. */ +TEST(gbuf_imports_multi_symbol_reinsert_after_target_delete) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t consumer = + cbm_gbuf_upsert_node(gb, "File", "consumer.py", "pkg.consumer", "consumer.py", 1, 1, + "{}"); + int64_t target = + cbm_gbuf_upsert_node(gb, "Method", "openapi", "pkg.FastAPI.openapi", "target.py", 1, 1, + "{}"); + ASSERT_GT(cbm_gbuf_insert_edge(gb, consumer, target, "IMPORTS", + "{\"local_name\":\"METHODS_WITH_BODY\"}"), + 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, consumer, target, "IMPORTS", + "{\"local_name\":\"REF_PREFIX\"}"), + 0); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "IMPORTS"), 2); + + ASSERT_EQ(cbm_gbuf_delete_by_file(gb, "target.py"), 1); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "IMPORTS"), 0); + + target = + cbm_gbuf_upsert_node(gb, "Method", "openapi", "pkg.FastAPI.openapi", "target.py", 1, 1, + "{}"); + ASSERT_GT(cbm_gbuf_insert_edge(gb, consumer, target, "IMPORTS", + "{\"local_name\":\"METHODS_WITH_BODY\"}"), + 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, consumer, target, "IMPORTS", + "{\"local_name\":\"REF_PREFIX\"}"), + 0); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "IMPORTS"), 2); + + cbm_gbuf_free(gb); + PASS(); +} + /* #768 hardening: the dedup key lives in a fixed-size stack buffer. Two long * local_names sharing a prefix must NOT silently collide when the verbatim * key would be truncated — the key builder re-keys oversized local_names with @@ -562,7 +797,7 @@ TEST(gbuf_delete_by_label_cascades_edges) { ASSERT_EQ(cbm_gbuf_edge_count(gb), 3); /* Delete all Class nodes — should remove fn→Cls edge only */ - cbm_gbuf_delete_by_label(gb, "Class"); + ASSERT_EQ(cbm_gbuf_delete_by_label(gb, "Class"), 0); ASSERT_EQ(cbm_gbuf_node_count(gb), 2); ASSERT_EQ(cbm_gbuf_edge_count(gb), 2); /* fn→meth and meth→fn survive */ @@ -576,6 +811,61 @@ TEST(gbuf_delete_by_label_cascades_edges) { PASS(); } +TEST(gbuf_delete_by_paths_cascades_edges) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_NOT_NULL(gb); + + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "a.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "b.go", 1, 5, "{}"); + int64_t c = cbm_gbuf_upsert_node(gb, "Function", "c", "pkg.c", "c.go", 1, 5, "{}"); + cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, b, c, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, c, a, "CALLS", "{}"); + + const char *paths[] = {"a.go", NULL, "b.go"}; + ASSERT_EQ(cbm_gbuf_delete_by_paths(gb, paths, (int)(sizeof(paths) / sizeof(paths[0]))), 2); + ASSERT_EQ(cbm_gbuf_node_count(gb), 1); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 0); + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "pkg.a")); + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "pkg.b")); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(gb, "pkg.c")); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_prune_orphan_folders_removes_nested_empty_context) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_NOT_NULL(gb); + + int64_t project = cbm_gbuf_upsert_node(gb, "Project", "test", "test", "", 0, 0, "{}"); + int64_t source_folder = + cbm_gbuf_upsert_node(gb, "Folder", "src", "test.src", "src", 0, 0, "{}"); + int64_t package_folder = + cbm_gbuf_upsert_node(gb, "Folder", "pkg", "test.src.pkg", "src/pkg", 0, 0, "{}"); + int64_t file = + cbm_gbuf_upsert_node(gb, "File", "a.go", "test.src.pkg.a", "src/pkg/a.go", 0, 0, "{}"); + ASSERT_GT(project, 0); + ASSERT_GT(source_folder, 0); + ASSERT_GT(package_folder, 0); + ASSERT_GT(file, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, project, source_folder, "CONTAINS_FOLDER", "{}"), 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, source_folder, package_folder, "CONTAINS_FOLDER", "{}"), 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, package_folder, file, "CONTAINS_FILE", "{}"), 0); + + ASSERT_EQ(cbm_gbuf_prune_orphan_folders(gb), 0); + ASSERT_EQ(cbm_gbuf_delete_by_file(gb, "src/pkg/a.go"), 1); + ASSERT_EQ(cbm_gbuf_prune_orphan_folders(gb), 2); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(gb, "test")); + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "test.src")); + ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "test.src.pkg")); + ASSERT_EQ(cbm_gbuf_node_count(gb), 1); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 0); + + cbm_gbuf_free(gb); + PASS(); +} + TEST(gbuf_node_count_empty) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); ASSERT_EQ(cbm_gbuf_node_count(gb), 0); @@ -610,8 +900,8 @@ TEST(gbuf_upsert_100_nodes_stress) { TEST(gbuf_edge_nonexistent_endpoints) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); - /* Edges with non-existent source/target IDs are accepted (no FK validation - * in the buffer — validation happens at flush time when remapping IDs) */ + /* Edge insertion stays append-oriented; the pre-dump invariant validator + * owns structural endpoint checks so producers can be diagnosed together. */ int64_t eid = cbm_gbuf_insert_edge(gb, 9999, 8888, "CALLS", "{}"); ASSERT_GT(eid, 0); ASSERT_EQ(cbm_gbuf_edge_count(gb), 1); @@ -619,6 +909,46 @@ TEST(gbuf_edge_nonexistent_endpoints) { PASS(); } +TEST(gbuf_validate_invariants_valid_graph) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_NOT_NULL(gb); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "pkg.b", "f.go", 6, 10, "{}"); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{}"), 0); + + char err[CBM_SZ_256]; + ASSERT_EQ(cbm_gbuf_validate_invariants(gb, err, sizeof(err)), 0); + ASSERT_STR_EQ(err, ""); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_dump_rejects_missing_edge_endpoint) { + char path[256]; + ASSERT_EQ(gbuf_make_temp_db(path, sizeof(path)), 0); + cbm_unlink(path); + + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + ASSERT_NOT_NULL(gb); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + ASSERT_GT(a, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, a, 9999, "CALLS", "{}"), 0); + + char err[CBM_SZ_256]; + ASSERT_NEQ(cbm_gbuf_validate_invariants(gb, err, sizeof(err)), 0); + ASSERT(strstr(err, "endpoint") != NULL); + ASSERT_NEQ(cbm_gbuf_dump_to_sqlite(gb, path), 0); + + FILE *f = fopen(path, "rb"); + ASSERT_NULL(f); + + cbm_gbuf_free(gb); + PASS(); +} + TEST(gbuf_edge_dedup_merges_properties) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); @@ -687,6 +1017,35 @@ TEST(gbuf_delete_edges_preserves_other_types) { PASS(); } +TEST(gbuf_delete_edges_by_type_matching_props) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Route", "/b", "__route__GET__/b", "f.go", 6, 10, "{}"); + int64_t c = cbm_gbuf_upsert_node(gb, "Route", "/c", "__route__GET__/c", "f.go", 11, 15, "{}"); + + cbm_gbuf_insert_edge(gb, a, b, "HANDLES", "{\"source\":\"prefix_decorator_bridge\"}"); + cbm_gbuf_insert_edge(gb, a, c, "HANDLES", "{\"handler\":\"pkg.a\"}"); + cbm_gbuf_insert_edge(gb, a, c, "CALLS", "{\"source\":\"prefix_decorator_bridge\"}"); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 3); + + int deleted = cbm_gbuf_delete_edges_by_type_matching_props( + gb, "HANDLES", "\"source\":\"prefix_decorator_bridge\""); + ASSERT_EQ(deleted, 1); + ASSERT_EQ(cbm_gbuf_edge_count(gb), 2); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 1); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "CALLS"), 1); + + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_target_type(gb, b, "HANDLES", &edges, &count); + ASSERT_EQ(count, 0); + cbm_gbuf_find_edges_by_target_type(gb, c, "HANDLES", &edges, &count); + ASSERT_EQ(count, 1); + + cbm_gbuf_free(gb); + PASS(); +} + TEST(gbuf_find_edges_by_target_type_multiple) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp"); int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}"); @@ -721,7 +1080,7 @@ TEST(gbuf_merge_overlapping_qns) { "{\"from\":\"dst\"}"); cbm_gbuf_upsert_node(dst, "Function", "unique_dst", "pkg.unique_dst", "u.go", 1, 5, "{}"); - /* src has same QN with different fields — src should win */ + /* src has the same QN with a richer source span, so it should win */ cbm_gbuf_upsert_node(src, "Method", "fn_new", "pkg.fn", "new.go", 20, 30, "{\"from\":\"src\"}"); cbm_gbuf_upsert_node(src, "Function", "unique_src", "pkg.unique_src", "s.go", 1, 5, "{}"); @@ -731,7 +1090,7 @@ TEST(gbuf_merge_overlapping_qns) { /* Total: 3 nodes (1 merged + 1 dst-only + 1 src-only) */ ASSERT_EQ(cbm_gbuf_node_count(dst), 3); - /* Verify src fields won for the overlapping QN */ + /* Verify the richer src fields won for the overlapping QN */ const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(dst, "pkg.fn"); ASSERT_NOT_NULL(n); ASSERT_STR_EQ(n->label, "Method"); @@ -749,6 +1108,154 @@ TEST(gbuf_merge_overlapping_qns) { PASS(); } +TEST(gbuf_merge_reindexes_label_and_name) { + enum { + COMPAT_DECL_START = 48, + COMPAT_DECL_END = 50, + COMPAT_DEF_START = 197, + COMPAT_DEF_END = 230, + }; + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + + cbm_gbuf_upsert_node(dst, "Macro", "OLD_NAME", "pkg.compat.cbm_getline", "compat.h", + COMPAT_DECL_START, COMPAT_DECL_END, "{}"); + cbm_gbuf_upsert_node(src, "Function", "cbm_getline", "pkg.compat.cbm_getline", + "compat.c", COMPAT_DEF_START, COMPAT_DEF_END, + "{\"loop_depth\":1,\"self_recursive\":false}"); + ASSERT_EQ(cbm_gbuf_merge(dst, src), 0); + + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(dst, "Macro", &nodes, &count), 0); + ASSERT_EQ(count, 0); + ASSERT_EQ(cbm_gbuf_find_by_label(dst, "Function", &nodes, &count), 0); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(nodes[0]->name, "cbm_getline"); + + ASSERT_EQ(cbm_gbuf_find_by_name(dst, "OLD_NAME", &nodes, &count), 0); + ASSERT_EQ(count, 0); + ASSERT_EQ(cbm_gbuf_find_by_name(dst, "cbm_getline", &nodes, &count), 0); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(nodes[0]->label, "Function"); + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_route_file_path_is_deterministic) { + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + const char *route_qn = "__route__GET__/items/{}"; + + cbm_gbuf_upsert_node(dst, "Route", "/items/{item_id}", route_qn, "z/last.py", 0, 0, "{}"); + cbm_gbuf_upsert_node(src, "Route", "/items/{id}", route_qn, "a/first.py", 0, 0, + "{\"method\":\"GET\",\"source\":\"decorator\"}"); + + int rc = cbm_gbuf_merge(dst, src); + ASSERT_EQ(rc, 0); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(dst, route_qn); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->name, "/items/{id}"); + ASSERT_STR_EQ(n->file_path, "a/first.py"); + ASSERT_STR_EQ(n->properties_json, "{\"method\":\"GET\",\"source\":\"decorator\"}"); + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_section_file_path_is_deterministic) { + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + const char *section_qn = "proj.docs.deployment.Implantacao"; + + cbm_gbuf_upsert_node(dst, "Section", "Implantacao", section_qn, "docs/deployment/index.md", + 1, 2, "{}"); + cbm_gbuf_upsert_node(src, "Section", "Implantacao", section_qn, "docs/deployment.md", 1, 2, + "{}"); + + int rc = cbm_gbuf_merge(dst, src); + ASSERT_EQ(rc, 0); + + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(dst, section_qn); + ASSERT_NOT_NULL(n); + ASSERT_STR_EQ(n->file_path, "docs/deployment.md"); + + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + PASS(); +} + +TEST(gbuf_merge_definition_source_prefers_richer_span) { + enum { + DECL_START_LINE = 7, + DECL_END_LINE = 7, + FULL_DEF_START_LINE = 34, + FULL_DEF_END_LINE = 43, + }; + const char *qn = "proj.TypeName"; + + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(dst, "Class", "TypeName", qn, "include/type.h", DECL_START_LINE, + DECL_END_LINE, "{\"source\":\"declaration\"}"); + cbm_gbuf_upsert_node(src, "Class", "TypeName", qn, "src/type.c", FULL_DEF_START_LINE, + FULL_DEF_END_LINE, "{\"source\":\"definition\"}"); + ASSERT_EQ(cbm_gbuf_merge(dst, src), 0); + ASSERT_EQ(assert_full_definition_source(dst, qn), 0); + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + + dst = cbm_gbuf_new("test", "/tmp"); + src = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(dst, "Class", "TypeName", qn, "src/type.c", FULL_DEF_START_LINE, + FULL_DEF_END_LINE, "{\"source\":\"definition\"}"); + cbm_gbuf_upsert_node(src, "Class", "TypeName", qn, "include/type.h", DECL_START_LINE, + DECL_END_LINE, "{\"source\":\"declaration\"}"); + ASSERT_EQ(cbm_gbuf_merge(dst, src), 0); + ASSERT_EQ(assert_full_definition_source(dst, qn), 0); + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + + PASS(); +} + +TEST(gbuf_merge_module_source_prefers_richer_span) { + enum { + INSTALL_START_LINE = 1, + INSTALL_PS_END_LINE = 155, + INSTALL_SH_END_LINE = 221, + }; + const char *qn = "proj.install"; + + cbm_gbuf_t *dst = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(dst, "Module", "install.ps1", qn, "install.ps1", INSTALL_START_LINE, + INSTALL_PS_END_LINE, "{\"source\":\"powershell\"}"); + cbm_gbuf_upsert_node(src, "Module", "install.sh", qn, "install.sh", INSTALL_START_LINE, + INSTALL_SH_END_LINE, "{\"source\":\"shell\"}"); + ASSERT_EQ(cbm_gbuf_merge(dst, src), 0); + ASSERT_EQ(assert_install_sh_module_source(dst, qn), 0); + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + + dst = cbm_gbuf_new("test", "/tmp"); + src = cbm_gbuf_new("test", "/tmp"); + cbm_gbuf_upsert_node(dst, "Module", "install.sh", qn, "install.sh", INSTALL_START_LINE, + INSTALL_SH_END_LINE, "{\"source\":\"shell\"}"); + cbm_gbuf_upsert_node(src, "Module", "install.ps1", qn, "install.ps1", INSTALL_START_LINE, + INSTALL_PS_END_LINE, "{\"source\":\"powershell\"}"); + ASSERT_EQ(cbm_gbuf_merge(dst, src), 0); + ASSERT_EQ(assert_install_sh_module_source(dst, qn), 0); + cbm_gbuf_free(dst); + cbm_gbuf_free(src); + + PASS(); +} + TEST(gbuf_merge_edge_dedup) { _Atomic int64_t shared = 1; cbm_gbuf_t *dst = cbm_gbuf_new_shared_ids("test", "/tmp", &shared); @@ -874,6 +1381,48 @@ TEST(gbuf_flush_verify_store_data) { PASS(); } +TEST(gbuf_flush_begin_failure_preserves_existing_project) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "proj", "/tmp/repo"), CBM_STORE_OK); + + cbm_node_t existing = { + .project = "proj", + .label = "Function", + .name = "existing", + .qualified_name = "proj::existing", + .file_path = "old.go", + .start_line = 1, + .end_line = 3, + .properties_json = "{}", + }; + ASSERT_GT(cbm_store_upsert_node(store, &existing), 0); + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 1); + + ASSERT_EQ(cbm_store_begin(store), CBM_STORE_OK); + + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/repo"); + ASSERT_NOT_NULL(gb); + cbm_gbuf_upsert_node(gb, "Function", "replacement", "proj::replacement", "new.go", 1, 5, + "{}"); + + ASSERT_NEQ(cbm_gbuf_flush_to_store(gb, store), 0); + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 1); + + cbm_node_t out = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, "proj", "proj::existing", &out), CBM_STORE_OK); + cbm_node_free_fields(&out); + ASSERT_EQ(cbm_store_find_node_by_qn(store, "proj", "proj::replacement", &out), + CBM_STORE_NOT_FOUND); + + ASSERT_EQ(cbm_store_rollback(store), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 1); + + cbm_gbuf_free(gb); + cbm_store_close(store); + PASS(); +} + TEST(gbuf_merge_into_store_preserves) { /* First, flush initial data via flush_to_store */ cbm_gbuf_t *gb1 = cbm_gbuf_new("proj", "/tmp/repo"); @@ -1008,8 +1557,223 @@ TEST(gbuf_flush_skips_orphan_edges) { PASS(); } +TEST(gbuf_flush_bulk_edges_preserves_buffer_properties) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/repo"); + int64_t ids[40]; + char name[CBM_SZ_32]; + char qn[CBM_SZ_64]; + for (int i = 0; i < 40; i++) { + snprintf(name, sizeof(name), "n%d", i); + snprintf(qn, sizeof(qn), "proj::n%d", i); + ids[i] = cbm_gbuf_upsert_node(gb, "Function", name, qn, "f.c", i + 1, i + 1, "{}"); + } + + cbm_gbuf_insert_edge(gb, ids[0], ids[1], "CALLS", "{\"first\":1}"); + cbm_gbuf_insert_edge(gb, ids[0], ids[1], "CALLS", "{\"second\":2}"); + for (int i = 1; i < 35; i++) { + cbm_gbuf_insert_edge(gb, ids[i], ids[i + 1], "CALLS", "{}"); + } + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_gbuf_flush_to_store(gb, store), 0); + ASSERT_EQ(cbm_store_count_nodes(store, "proj"), 40); + ASSERT_EQ(cbm_store_count_edges(store, "proj"), 35); + + cbm_node_t first = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, "proj", "proj::n0", &first), CBM_STORE_OK); + cbm_edge_t *edges = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, first.id, "CALLS", &edges, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT(strstr(edges[0].properties_json, "\"first\":1") == NULL); + ASSERT(strstr(edges[0].properties_json, "\"second\":2") != NULL); + cbm_store_free_edges(edges, count); + cbm_node_free_fields(&first); + + cbm_store_close(store); + cbm_gbuf_free(gb); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ +/* B1 pipeline-path isolation probe (#23): cbm_write_db is clean 10/10 even with + * variable-length records (decisive negative this session), and the streaming + * dump for <65536 nodes is byte-identical to cbm_write_db at the writer level + * (DUMP_PARTITION_NODES=1<<16 → one partition = all nodes). So the ONLY hop the + * real pipeline runs that the writer test skips is the gbuf→dump handoff: + * build_dump_nodes / build_dump_edges / temp_to_final ID remap, fed by a + * merge-populated gbuf (parallel workers → cbm_gbuf_merge). This test drives + * that exact path with variable-length heap properties_json + a merged worker + * gbuf. If it corrupts → root cause isolated in the handoff. If clean → the + * bug lives in extraction-population (tree-sitter), which needs the real repo. */ +TEST(gbuf_dump_pipeline_path_integrity) { + char path[256]; + ASSERT_EQ(gbuf_make_temp_db(path, sizeof(path)), 0); + + const int N = 15000; /* < 65536 → one dump partition, mirrors fastapi scale */ + const int W = 3000; /* worker gbuf nodes, merged in (exercises remap) */ + const int E = 50000; + + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/gbuf_pipeline_root"); + ASSERT_NOT_NULL(gb); + + /* Variable-length heap properties_json, exactly like real extraction emits. */ + static const int plens[] = {20, 200, 800, 1500, 50, 400, 1000, 100}; + char name[64], qn[96], props[2048]; + for (int i = 0; i < N; i++) { + snprintf(name, sizeof(name), "fn_%d", i); + snprintf(qn, sizeof(qn), "proj.mod.fn_%d", i); + int target = plens[i % 8]; + int padlen = target - 8; /* {"k":""} overhead */ + if (padlen < 0) padlen = 0; + if (padlen > 2040) padlen = 2040; + props[0] = '{'; props[1] = '"'; props[2] = 'k'; props[3] = '"'; props[4] = ':'; + props[5] = '"'; + memset(props + 6, 'y', (size_t)padlen); + props[6 + padlen] = '"'; + props[6 + padlen + 1] = '}'; + props[6 + padlen + 2] = '\0'; + int64_t id = cbm_gbuf_upsert_node(gb, "Function", name, qn, + (i % 400 == 0) ? "src/base.py" : "src/mod.py", + i + 1, i + 2, props); + ASSERT_GT(id, 0); + } + for (int i = 0; i < E; i++) { + int64_t s = (i % N) + 1; + int64_t t = ((i / N) % N) + 1; + if (s == t) t = (t % N) + 1; + cbm_gbuf_insert_edge(gb, s, t, "CALLS", "{}"); + } + + /* Worker gbuf: some NEW qns + some colliding qns, then merge (parallel-pipeline + * simulation — exercises cbm_gbuf_merge + the QN-collision ID remap). */ + _Atomic int64_t shared_ids; + atomic_init(&shared_ids, cbm_gbuf_next_id(gb)); + cbm_gbuf_t *gw = cbm_gbuf_new_shared_ids("proj", "/tmp/gbuf_pipeline_root", &shared_ids); + ASSERT_NOT_NULL(gw); + for (int i = 0; i < W; i++) { + if (i % 2 == 0) { + /* collide with an existing main qn (merge_update_existing path) */ + snprintf(qn, sizeof(qn), "proj.mod.fn_%d", i % 1000); + } else { + /* brand-new qn (merge_copy_new_node path) */ + snprintf(qn, sizeof(qn), "proj.worker.w_%d", i); + } + snprintf(name, sizeof(name), "w_%d", i); + cbm_gbuf_upsert_node(gw, "Function", name, qn, "src/worker.py", 1, 2, + "{\"w\":true}"); + } + ASSERT_EQ(cbm_gbuf_merge(gb, gw), 0); + + /* The dump path under test. */ + ASSERT_EQ(cbm_gbuf_dump_to_sqlite(gb, path), 0); + + /* Verify: structural integrity + exact root_path round-trip + counts. */ + sqlite3 *db = NULL; + ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK); + sqlite3_stmt *stmt = NULL; + + sqlite3_prepare_v2(db, "PRAGMA integrity_check", -1, &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "ok"); + sqlite3_finalize(stmt); + + sqlite3_prepare_v2(db, "SELECT root_path FROM projects", -1, &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "/tmp/gbuf_pipeline_root"); + sqlite3_finalize(stmt); + + sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM nodes", -1, &stmt, NULL); + sqlite3_step(stmt); + int ncount = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + /* N main + (W/2) new worker qns (the colliding half merges into existing). */ + ASSERT_EQ(ncount, N + (W / 2)); + + sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM edges", -1, &stmt, NULL); + sqlite3_step(stmt); + int ecount = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_GT(ecount, 0); + + sqlite3_close(db); + cbm_unlink(path); + cbm_gbuf_free(gw); + cbm_gbuf_free(gb); + PASS(); +} + +/* Regression: a RELATIVE root_path (e.g. ".") must not cause the post-dump + * verify to delete a valid DB. The integrity check flags non-absolute + * root_paths as bad_root_path, but that's a cosmetic project-row defect + * (path_only) — the node/edge data is intact. The dump-verify must RETAIN + * (path_only) like #557, not delete. Caught by self-indexing the repo with + * repo_path="." (which failed with status=error before the fix). */ +TEST(gbuf_dump_relative_root_path_retained) { + char path[256]; + ASSERT_EQ(gbuf_make_temp_db(path, sizeof(path)), 0); + + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "."); + ASSERT_NOT_NULL(gb); + cbm_gbuf_upsert_node(gb, "Function", "main", "proj.main", "main.c", 1, 2, "{}"); + + /* Must succeed (DB retained) despite the relative "." root_path. */ + ASSERT_EQ(cbm_gbuf_dump_to_sqlite(gb, path), 0); + + /* The DB file must still exist (not deleted by the verify). */ + FILE *f = fopen(path, "rb"); + ASSERT_NOT_NULL(f); + if (f) { + fclose(f); + } + + cbm_unlink(path); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(gbuf_dump_failure_before_replace_keeps_existing_db) { + static const char *fail_env = CBM_TEST_FAIL_GBUF_DUMP_BEFORE_REPLACE; + char saved_fail[CBM_SZ_32] = {0}; + bool had_fail = cbm_safe_getenv(fail_env, saved_fail, sizeof(saved_fail), NULL) != NULL; + + char path[256]; + ASSERT_EQ(gbuf_make_temp_db(path, sizeof(path)), 0); + + cbm_gbuf_t *old_gb = cbm_gbuf_new("proj", "/tmp/repo"); + ASSERT_NOT_NULL(old_gb); + cbm_gbuf_upsert_node(old_gb, "Function", "old", "proj.old", "old.c", 1, 2, "{}"); + ASSERT_EQ(cbm_gbuf_dump_to_sqlite(old_gb, path), 0); + cbm_gbuf_free(old_gb); + + ASSERT(gbuf_store_has_qn(path, "proj", "proj.old")); + ASSERT(!gbuf_store_has_qn(path, "proj", "proj.new")); + + cbm_gbuf_t *new_gb = cbm_gbuf_new("proj", "/tmp/repo"); + ASSERT_NOT_NULL(new_gb); + cbm_gbuf_upsert_node(new_gb, "Function", "new", "proj.new", "new.c", 1, 2, "{}"); + + cbm_setenv(fail_env, "1", 1); + int dump_rc = cbm_gbuf_dump_to_sqlite(new_gb, path); + + if (had_fail) { + cbm_setenv(fail_env, saved_fail, 1); + } else { + cbm_unsetenv(fail_env); + } + + ASSERT_NEQ(dump_rc, 0); + ASSERT(gbuf_store_has_qn(path, "proj", "proj.old")); + ASSERT(!gbuf_store_has_qn(path, "proj", "proj.new")); + + cbm_gbuf_free(new_gb); + cbm_unlink(path); + PASS(); +} + SUITE(graph_buffer) { /* Original tests */ RUN_TEST(gbuf_create_free); @@ -1018,17 +1782,20 @@ SUITE(graph_buffer) { RUN_TEST(gbuf_upsert_updates); RUN_TEST(gbuf_find_by_id); RUN_TEST(gbuf_find_by_label); + RUN_TEST(gbuf_upsert_reindexes_label_and_name); RUN_TEST(gbuf_find_by_name); RUN_TEST(gbuf_delete_by_label); RUN_TEST(gbuf_insert_edge); RUN_TEST(gbuf_edge_dedup); RUN_TEST(gbuf_imports_multi_symbol_dedup); + RUN_TEST(gbuf_imports_multi_symbol_reinsert_after_target_delete); RUN_TEST(gbuf_imports_long_local_name_no_collision); RUN_TEST(gbuf_find_edges_by_source_type); RUN_TEST(gbuf_find_edges_by_target_type); RUN_TEST(gbuf_find_edges_by_type); RUN_TEST(gbuf_delete_edges_by_type); RUN_TEST(gbuf_edge_count_by_type); + RUN_TEST(gbuf_delete_edges_by_type_matching_props); RUN_TEST(gbuf_dump_empty); RUN_TEST(gbuf_flush_to_store); RUN_TEST(gbuf_many_nodes); @@ -1037,17 +1804,25 @@ SUITE(graph_buffer) { RUN_TEST(gbuf_upsert_null_qn); RUN_TEST(gbuf_upsert_empty_qn); RUN_TEST(gbuf_upsert_same_qn_updates_all_fields); + RUN_TEST(gbuf_route_upsert_file_path_is_deterministic); + RUN_TEST(gbuf_section_upsert_file_path_is_deterministic); + RUN_TEST(gbuf_upsert_definition_source_prefers_richer_span); + RUN_TEST(gbuf_upsert_module_source_prefers_richer_span); RUN_TEST(gbuf_upsert_long_qn); RUN_TEST(gbuf_find_by_qn_missing); RUN_TEST(gbuf_find_by_id_missing); RUN_TEST(gbuf_find_by_label_no_matches); RUN_TEST(gbuf_find_by_name_multiple); RUN_TEST(gbuf_delete_by_label_cascades_edges); + RUN_TEST(gbuf_delete_by_paths_cascades_edges); + RUN_TEST(gbuf_prune_orphan_folders_removes_nested_empty_context); RUN_TEST(gbuf_node_count_empty); RUN_TEST(gbuf_upsert_100_nodes_stress); /* Edge edge cases */ RUN_TEST(gbuf_edge_nonexistent_endpoints); + RUN_TEST(gbuf_validate_invariants_valid_graph); + RUN_TEST(gbuf_dump_rejects_missing_edge_endpoint); RUN_TEST(gbuf_edge_dedup_merges_properties); RUN_TEST(gbuf_edge_count_empty); RUN_TEST(gbuf_edge_count_by_type_missing); @@ -1056,6 +1831,11 @@ SUITE(graph_buffer) { /* Merge tests */ RUN_TEST(gbuf_merge_overlapping_qns); + RUN_TEST(gbuf_merge_reindexes_label_and_name); + RUN_TEST(gbuf_merge_route_file_path_is_deterministic); + RUN_TEST(gbuf_merge_section_file_path_is_deterministic); + RUN_TEST(gbuf_merge_definition_source_prefers_richer_span); + RUN_TEST(gbuf_merge_module_source_prefers_richer_span); RUN_TEST(gbuf_merge_edge_dedup); RUN_TEST(gbuf_merge_empty_src_into_populated_dst); RUN_TEST(gbuf_merge_populated_src_into_empty_dst); @@ -1064,12 +1844,20 @@ SUITE(graph_buffer) { /* Flush/merge-into-store tests */ RUN_TEST(gbuf_flush_to_store_null); RUN_TEST(gbuf_flush_verify_store_data); + RUN_TEST(gbuf_flush_begin_failure_preserves_existing_project); RUN_TEST(gbuf_merge_into_store_preserves); RUN_TEST(gbuf_flush_skips_orphan_edges); + RUN_TEST(gbuf_flush_bulk_edges_preserves_buffer_properties); /* Shared ID tests */ RUN_TEST(gbuf_shared_ids_unique); RUN_TEST(gbuf_shared_ids_null_fallback); RUN_TEST(gbuf_next_id_set_next_id_roundtrip); RUN_TEST(gbuf_next_id_null_safe); + + /* B1 pipeline-path isolation (#23) */ + RUN_TEST(gbuf_dump_pipeline_path_integrity); + /* Relative root_path retain regression (#57 self-index finding) */ + RUN_TEST(gbuf_dump_relative_root_path_retained); + RUN_TEST(gbuf_dump_failure_before_replace_keeps_existing_db); } diff --git a/tests/test_graph_diff.h b/tests/test_graph_diff.h new file mode 100644 index 000000000..61e1e1dfd --- /dev/null +++ b/tests/test_graph_diff.h @@ -0,0 +1,254 @@ +/* + * test_graph_diff.h - Canonical graph comparison helpers for tests. + * + * These helpers compare graph facts by stable keys instead of transient row IDs. + * They are intentionally test-only so production store APIs stay unchanged. + */ +#ifndef TEST_GRAPH_DIFF_H +#define TEST_GRAPH_DIFF_H + +#include + +#include "../src/foundation/compat.h" +#include "../src/foundation/constants.h" + +#include +#include +#include + +enum { TG_ROW_SET_INIT_CAP = CBM_SZ_128 }; + +typedef struct { + char **items; + int count; + int cap; +} tg_row_set_t; + +static inline void tg_row_set_free(tg_row_set_t *rows) { + if (!rows) { + return; + } + for (int i = 0; i < rows->count; i++) { + free(rows->items[i]); + } + free(rows->items); + rows->items = NULL; + rows->count = 0; + rows->cap = 0; +} + +static inline int tg_set_error(char *err, size_t err_sz, const char *msg) { + if (err && err_sz > 0) { + int n = snprintf(err, err_sz, "%s", msg ? msg : "graph diff failed"); + if (n < 0 || (size_t)n >= err_sz) { + err[err_sz - 1] = '\0'; + } + } + return CBM_NOT_FOUND; +} + +static inline int tg_row_set_push(tg_row_set_t *rows, const char *row, char *err, size_t err_sz) { + if (rows->count == rows->cap) { + int next_cap = rows->cap ? rows->cap * PAIR_LEN : TG_ROW_SET_INIT_CAP; + char **next = (char **)realloc(rows->items, (size_t)next_cap * sizeof(*next)); + if (!next) { + return tg_set_error(err, err_sz, "graph diff: out of memory growing row set"); + } + rows->items = next; + rows->cap = next_cap; + } + rows->items[rows->count] = cbm_strdup(row ? row : ""); + if (!rows->items[rows->count]) { + return tg_set_error(err, err_sz, "graph diff: out of memory copying row"); + } + rows->count++; + return 0; +} + +static inline int tg_collect_query(sqlite3 *db, const char *project, const char *sql, + tg_row_set_t *rows, char *err, size_t err_sz) { + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + return tg_set_error(err, err_sz, sqlite3_errmsg(db)); + } + rc = sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); + if (rc != SQLITE_OK) { + sqlite3_finalize(stmt); + return tg_set_error(err, err_sz, sqlite3_errmsg(db)); + } + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const unsigned char *txt = sqlite3_column_text(stmt, 0); + if (tg_row_set_push(rows, (const char *)txt, err, err_sz) != 0) { + sqlite3_finalize(stmt); + return CBM_NOT_FOUND; + } + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + return tg_set_error(err, err_sz, sqlite3_errmsg(db)); + } + return 0; +} + +static inline int tg_compare_rows(const char *kind, const tg_row_set_t *left, + const tg_row_set_t *right, char *err, size_t err_sz) { + if (left->count != right->count) { + if (err && err_sz > 0) { + int n = snprintf(err, err_sz, "%s count differs: left=%d right=%d", kind, + left->count, right->count); + if (n < 0 || (size_t)n >= err_sz) { + err[err_sz - 1] = '\0'; + } + } + return CBM_NOT_FOUND; + } + int left_idx = 0; + int right_idx = 0; + while (left_idx < left->count && right_idx < right->count) { + int cmp = strcmp(left->items[left_idx], right->items[right_idx]); + if (cmp == 0) { + left_idx++; + right_idx++; + continue; + } + if (cmp < 0) { + if (err && err_sz > 0) { + int n = snprintf(err, err_sz, + "%s left-only row %d:\n left: %s\n right row %d: %s", kind, + left_idx, left->items[left_idx], right_idx, + right->items[right_idx]); + if (n < 0 || (size_t)n >= err_sz) { + err[err_sz - 1] = '\0'; + } + } + return CBM_NOT_FOUND; + } + if (err && err_sz > 0) { + int n = snprintf(err, err_sz, + "%s right-only row %d:\n right: %s\n left row %d: %s", kind, + right_idx, right->items[right_idx], left_idx, left->items[left_idx]); + if (n < 0 || (size_t)n >= err_sz) { + err[err_sz - 1] = '\0'; + } + } + return CBM_NOT_FOUND; + } + if (left_idx < left->count || right_idx < right->count) { + if (err && err_sz > 0) { + int n = snprintf(err, err_sz, "%s exhausted unevenly: left_row=%d right_row=%d", kind, + left_idx, right_idx); + if (n < 0 || (size_t)n >= err_sz) { + err[err_sz - 1] = '\0'; + } + } + return CBM_NOT_FOUND; + } + return 0; +} + +static inline int tg_compare_query(sqlite3 *left_db, sqlite3 *right_db, const char *project, + const char *kind, const char *sql, char *err, + size_t err_sz) { + tg_row_set_t left = {0}; + tg_row_set_t right = {0}; + int rc = tg_collect_query(left_db, project, sql, &left, err, err_sz); + if (rc == 0) { + rc = tg_collect_query(right_db, project, sql, &right, err, err_sz); + } + if (rc == 0) { + rc = tg_compare_rows(kind, &left, &right, err, err_sz); + } + tg_row_set_free(&left); + tg_row_set_free(&right); + return rc; +} + +static inline int cbm_test_compare_canonical_graphs(const char *left_db_path, + const char *right_db_path, + const char *project, char *err, + size_t err_sz) { + static const char *nodes_sql = + "SELECT quote(label) || char(9) || quote(name) || char(9) || " + "quote(qualified_name) || char(9) || quote(coalesce(file_path,'')) || char(9) || " + "start_line || char(9) || end_line || char(9) || " /* properties below */ + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(n.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '') " + "FROM nodes n WHERE project = ?1 " + "ORDER BY label, name, qualified_name, coalesce(file_path,''), start_line, end_line, " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(n.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '');"; + static const char *edges_sql = + "SELECT quote(s.label) || char(9) || quote(s.qualified_name) || char(9) || " + "quote(coalesce(s.file_path,'')) || char(9) || s.start_line || char(9) || " + "s.end_line || char(9) || quote(t.label) || char(9) || quote(t.qualified_name) || " + "char(9) || quote(coalesce(t.file_path,'')) || char(9) || t.start_line || char(9) || " + "t.end_line || char(9) || quote(e.type) || char(9) || " + "COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(e.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '') " + "FROM edges e " + "JOIN nodes s ON s.id = e.source_id " + "JOIN nodes t ON t.id = e.target_id " + "WHERE e.project = ?1 " + "ORDER BY s.label, s.qualified_name, coalesce(s.file_path,''), s.start_line, s.end_line, " + "t.label, t.qualified_name, coalesce(t.file_path,''), t.start_line, t.end_line, " + "e.type, COALESCE((SELECT group_concat(item, char(30)) FROM (" + "SELECT quote(je.key) || '=' || je.type || '=' || " + "COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item " + "FROM json_each(e.properties) AS je " + "ORDER BY je.key, je.type, CAST(je.value AS TEXT)" + ")), '');"; + static const char *hashes_sql = + "SELECT quote(rel_path) || char(9) || quote(sha256) || char(9) || mtime_ns || char(9) || " + "size FROM file_hashes WHERE project = ?1 ORDER BY rel_path;"; + + sqlite3 *left_db = NULL; + sqlite3 *right_db = NULL; + int rc = sqlite3_open_v2(left_db_path, &left_db, SQLITE_OPEN_READONLY, NULL); + if (rc != SQLITE_OK) { + if (left_db) { + tg_set_error(err, err_sz, sqlite3_errmsg(left_db)); + sqlite3_close(left_db); + } else { + tg_set_error(err, err_sz, "graph diff: cannot open left DB"); + } + return CBM_NOT_FOUND; + } + rc = sqlite3_open_v2(right_db_path, &right_db, SQLITE_OPEN_READONLY, NULL); + if (rc != SQLITE_OK) { + if (right_db) { + tg_set_error(err, err_sz, sqlite3_errmsg(right_db)); + sqlite3_close(right_db); + } else { + tg_set_error(err, err_sz, "graph diff: cannot open right DB"); + } + sqlite3_close(left_db); + return CBM_NOT_FOUND; + } + + rc = tg_compare_query(left_db, right_db, project, "canonical nodes", nodes_sql, err, err_sz); + if (rc == 0) { + rc = tg_compare_query(left_db, right_db, project, "canonical edges", edges_sql, err, err_sz); + } + if (rc == 0) { + rc = tg_compare_query(left_db, right_db, project, "file hashes", hashes_sql, err, err_sz); + } + + sqlite3_close(right_db); + sqlite3_close(left_db); + return rc; +} + +#endif /* TEST_GRAPH_DIFF_H */ diff --git a/tests/test_helpers.h b/tests/test_helpers.h index 4ed67867d..aa770c20f 100644 --- a/tests/test_helpers.h +++ b/tests/test_helpers.h @@ -14,14 +14,18 @@ #include "../src/foundation/compat.h" #include "../src/foundation/compat_fs.h" +#include "../src/foundation/constants.h" #include "../src/foundation/platform.h" #include +#include #include #include #include #ifdef _WIN32 #include "../src/foundation/win_utf8.h" +#else +#include #endif /* ── Path building ────────────────────────────────────────────── */ @@ -82,6 +86,34 @@ static inline int th_append_file(const char *path, const char *content) { return 0; } +/* Write a config row without public-setter validation. Tests use this only to + * model retained databases from older builds or manual edits. */ +static inline int th_set_raw_config_value(const char *cache_dir, const char *key, + const char *value) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/_config.db", cache_dir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + sqlite3 *db = NULL; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_open(path, &db); + if (rc == SQLITE_OK) { + rc = sqlite3_prepare_v2(db, "INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2)", + -1, &stmt, NULL); + } + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt) == SQLITE_DONE ? SQLITE_OK : sqlite3_errcode(db); + } + sqlite3_finalize(stmt); + if (db && sqlite3_close(db) != SQLITE_OK) { + rc = SQLITE_BUSY; + } + return rc == SQLITE_OK ? 0 : -1; +} + /* ── Directory creation ───────────────────────────────────────── */ /* Create a directory and all parents. Returns 0 on success. */ @@ -153,6 +185,70 @@ static inline int th_rmtree(const char *path) { return rc; } +/* Put a fixture's write time unambiguously before or after the current wall + * clock. This avoids sleeps and remains deterministic on coarse-timestamp + * filesystems used by containers and Windows test environments. */ +static inline bool th_shift_file_time_for_cache_test(const char *path, bool future) { + enum { + TH_CACHE_TIMESTAMP_SETTLE_SECONDS = 2, + TH_WINDOWS_FILETIME_TICKS_PER_SECOND = 10000000, + }; + if (!path) { + return false; + } +#ifdef _WIN32 + wchar_t *wide = cbm_path_to_wide(path); + if (!wide) { + return false; + } + HANDLE file = CreateFileW(wide, FILE_WRITE_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + free(wide); + FILETIME now; + GetSystemTimeAsFileTime(&now); + uint64_t ticks = ((uint64_t)now.dwHighDateTime << 32U) | now.dwLowDateTime; + uint64_t delta = (uint64_t)TH_CACHE_TIMESTAMP_SETTLE_SECONDS * + TH_WINDOWS_FILETIME_TICKS_PER_SECOND; + bool ok = file != INVALID_HANDLE_VALUE && + (future ? ticks <= UINT64_MAX - delta : ticks > delta); + if (ok) { + ticks = future ? ticks + delta : ticks - delta; + FILETIME shifted = { + .dwLowDateTime = (DWORD)ticks, + .dwHighDateTime = (DWORD)(ticks >> 32U), + }; + ok = SetFileTime(file, NULL, NULL, &shifted) != 0; + } + if (file != INVALID_HANDLE_VALUE && CloseHandle(file) == 0) { + ok = false; + } + return ok; +#else + struct timespec now; + if (clock_gettime(CLOCK_REALTIME, &now) != 0 || + (!future && now.tv_sec <= TH_CACHE_TIMESTAMP_SETTLE_SECONDS)) { + return false; + } + time_t shifted_sec = + now.tv_sec + (future ? TH_CACHE_TIMESTAMP_SETTLE_SECONDS + : -TH_CACHE_TIMESTAMP_SETTLE_SECONDS); + struct timespec times[2] = { + {.tv_sec = shifted_sec, .tv_nsec = now.tv_nsec}, + {.tv_sec = shifted_sec, .tv_nsec = now.tv_nsec}, + }; + return utimensat(AT_FDCWD, path, times, 0) == 0; +#endif +} + +static inline bool th_backdate_file_for_cache_test(const char *path) { + return th_shift_file_time_for_cache_test(path, false); +} + +static inline bool th_futuredate_file_for_cache_test(const char *path) { + return th_shift_file_time_for_cache_test(path, true); +} + /* ── Temp directory creation ──────────────────────────────────── */ /* Create a temporary directory. Returns static buffer with path. diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 5f18696e3..ea27d2117 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -462,6 +462,7 @@ TEST(httpd_resolves_bare_binary_path_from_path) { TEST(httpd_listen_ephemeral_port) { cbm_httpd_t *d = cbm_httpd_listen(0); ASSERT_NOT_NULL(d); + ASSERT_TRUE(cbm_httpd_listener_close_on_exec(d)); int port = cbm_httpd_port(d); ASSERT_GT(port, 0); /* accept with a short timeout and no client → NULL, promptly */ @@ -471,6 +472,20 @@ TEST(httpd_listen_ephemeral_port) { PASS(); } +TEST(httpd_accepted_socket_close_on_exec) { + cbm_httpd_t *d = cbm_httpd_listen(0); + ASSERT_NOT_NULL(d); + th_sock_t client = th_connect(cbm_httpd_port(d)); + ASSERT_TRUE(client != TH_SOCK_BAD); + cbm_http_conn_t *c = cbm_httpd_accept(d, 1000); + ASSERT_NOT_NULL(c); + ASSERT_TRUE(cbm_http_conn_close_on_exec(c)); + cbm_httpd_conn_close(c); + th_sock_close(client); + cbm_httpd_close(d); + PASS(); +} + TEST(httpd_listen_port_collision_returns_null) { cbm_httpd_t *d1 = cbm_httpd_listen(0); ASSERT_NOT_NULL(d1); @@ -2032,6 +2047,147 @@ TEST(ui_server_browse_wide_dir_no_overflow) { /* ── Suite ────────────────────────────────────────────────────── */ +/* ── CORS and /rpc coverage restored from the merge base ────────────────── + * Upstream deleted ui_server_cors_localhost_reflected, + * ui_server_cors_evil_origin_not_reflected and ui_server_rpc_initialize; the + * merge base and api-consolidation both had all three, and update_cors() is + * still live (src/ui/http_server.c:110). Origin reflection with no test + * asserting which origins are refused is exactly the coverage a merge must not + * drop silently. + * + * The localhost case is restored STRENGTHENED rather than verbatim. The old + * test asserted that http://localhost:5173 is reflected; the merged + * origin_is_same_server() (:89) now reflects only the server's OWN port, + * because "a different localhost port is a different principal" (:109). + * Restoring the old assertion would pin the looser policy back in place, so it + * is replaced by two tests: the same-port origin IS reflected, and a different + * localhost port is NOT. */ +TEST(ui_server_cors_same_server_origin_reflected) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + int port = cbm_http_server_port(ts.srv); + + /* Both loopback spellings of this server's own origin are reflected. + * + * Each spelling is sent with a Host header that AGREES with it, because + * request_passes_http_security() (:1716-1721) additionally requires + * origin_matches_host(): the origin's loopback spelling must match the + * Host's. That is upstream's tightening and it costs nothing here — a + * browser always sends Origin and Host consistently for a same-origin + * request — while closing the gap where a page could claim the loopback + * spelling the Host header does not use. Omitting Host would leave the + * helper's fixed "Host: 127.0.0.1" in place and make the localhost + * iteration fail 403, testing the policy rather than the reflection. */ + const char *const hosts[] = {"localhost", "127.0.0.1"}; + for (size_t i = 0; i < sizeof(hosts) / sizeof(hosts[0]); i++) { + char origin[64]; + snprintf(origin, sizeof(origin), "http://%s:%d", hosts[i], port); + char request[256]; + snprintf(request, sizeof(request), + "OPTIONS /rpc HTTP/1.1\r\nHost: %s:%d\r\nOrigin: %s\r\n\r\n", hosts[i], port, + origin); + char resp[4096]; + int n = th_http(port, request, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 204); + char expected[128]; + snprintf(expected, sizeof(expected), "Access-Control-Allow-Origin: %s", origin); + ASSERT_NOT_NULL(strstr(resp, expected)); + } + + th_server_stop(&ts); + PASS(); +} + +TEST(ui_server_cors_other_localhost_port_not_reflected) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + int port = cbm_http_server_port(ts.srv); + + /* A different localhost port is a different principal, so it must not be + * reflected even though it is loopback. Pick a port that is not ours. */ + int other = port == 5173 ? 5174 : 5173; + char request[256]; + snprintf(request, sizeof(request), + "OPTIONS /rpc HTTP/1.1\r\nOrigin: http://localhost:%d\r\n\r\n", other); + char resp[4096]; + int n = th_http(port, request, resp, sizeof(resp)); + + ASSERT_GT(n, 0); + /* Refused outright rather than served without a CORS header. Serving a + * foreign origin and merely withholding Access-Control-Allow-Origin relies + * on the CLIENT to enforce the boundary; 403 enforces it at the server, so + * a non-browser caller cannot reach the route either. Withholding the + * header is still asserted, so neither half of the contract can regress. */ + ASSERT_EQ(th_status(resp), 403); + ASSERT_NULL(strstr(resp, "Access-Control-Allow-Origin")); + th_server_stop(&ts); + PASS(); +} + +TEST(ui_server_cors_evil_origin_not_reflected) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + char resp[4096]; + int n = th_http(cbm_http_server_port(ts.srv), + "OPTIONS /rpc HTTP/1.1\r\n" + "Origin: http://evil.example.com\r\n\r\n", + resp, sizeof(resp)); + ASSERT_GT(n, 0); + /* Same server-side refusal as the wrong-port case above. */ + ASSERT_EQ(th_status(resp), 403); + ASSERT_NULL(strstr(resp, "Access-Control-Allow-Origin")); + th_server_stop(&ts); + PASS(); +} + +/* /rpc is NOT a general MCP endpoint: rpc_is_allowed_for_ui() + * (src/ui/http_server.c:1628-1643) admits only tools/call for list_projects and + * get_code_snippet. That matters because this port is reachable from a browser, + * where a general endpoint would expose index_repository and delete_project to + * any page that got past the origin check. `initialize` is therefore refused. + * + * Both halves are asserted in one test so neither can regress alone: widening + * the allowlist breaks the refusal half, and breaking dispatch breaks the + * allowed half. Asserting only the refusal would pass on a server that + * refuses everything. */ +TEST(ui_server_rpc_initialize) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + int port = cbm_http_server_port(ts.srv); + + const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"t\",\"version\":\"0\"}}}"; + char req[1024]; + snprintf(req, sizeof(req), + "POST /rpc HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n\r\n%s", + (int)strlen(body), body); + char resp[8192]; + int n = th_http(port, req, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 403); + ASSERT_NOT_NULL(strstr(resp, "UI RPC method is not allowed")); + + const char *allowed_body = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"; + snprintf(req, sizeof(req), + "POST /rpc HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n\r\n%s", + (int)strlen(allowed_body), allowed_body); + n = th_http(port, req, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\"")); + + th_server_stop(&ts); + PASS(); +} + SUITE(httpd) { RUN_TEST(ui_server_browse_wide_dir_no_overflow); /* Parser / helpers */ @@ -2057,6 +2213,7 @@ SUITE(httpd) { /* Transport */ RUN_TEST(httpd_listen_ephemeral_port); + RUN_TEST(httpd_accepted_socket_close_on_exec); RUN_TEST(httpd_listen_port_collision_returns_null); RUN_TEST(httpd_close_refuses_while_connection_owns_listener); @@ -2064,6 +2221,11 @@ SUITE(httpd) { RUN_TEST(ui_server_rejects_non_loopback_host); RUN_TEST(ui_server_unknown_path_404); RUN_TEST(ui_server_process_kill_route_is_unavailable); + /* restored from the merge base; localhost case strengthened */ + RUN_TEST(ui_server_cors_same_server_origin_reflected); + RUN_TEST(ui_server_cors_other_localhost_port_not_reflected); + RUN_TEST(ui_server_cors_evil_origin_not_reflected); + RUN_TEST(ui_server_rpc_initialize); RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor); RUN_TEST(ui_server_free_never_joins_active_index_worker); RUN_TEST(ui_server_root_serves_stub_404); diff --git a/tests/test_httplink.c b/tests/test_httplink.c new file mode 100644 index 000000000..360e0c6f4 --- /dev/null +++ b/tests/test_httplink.c @@ -0,0 +1,954 @@ +/* + * test_httplink.c — Tests for HTTP route discovery and cross-service linking. + * + * Port of Go internal/httplink/ test files: + * - similarity_test.go (4 tests) + * - httplink_test.go (32 tests) + * - config_test.go (5 tests) + * - langparity_test.go (2 tests) + * + * Total: 43 Go tests → 43 C tests + */ +#include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" +#include "test_framework.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* ═══════════════════════════════════════════════════════════════════ + * Similarity tests (port of similarity_test.go) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_levenshtein_distance) { + ASSERT_EQ(cbm_levenshtein_distance("", ""), 0); + ASSERT_EQ(cbm_levenshtein_distance("abc", ""), 3); + ASSERT_EQ(cbm_levenshtein_distance("", "abc"), 3); + ASSERT_EQ(cbm_levenshtein_distance("abc", "abc"), 0); + ASSERT_EQ(cbm_levenshtein_distance("kitten", "sitting"), 3); + ASSERT_EQ(cbm_levenshtein_distance("api/orders", "api/order"), 1); + ASSERT_EQ(cbm_levenshtein_distance("/api/v1/orders", "/api/v2/orders"), 1); + PASS(); +} + +TEST(httplink_normalized_levenshtein) { + double v; + + v = cbm_normalized_levenshtein("abc", "abc"); + ASSERT_FLOAT_EQ(v, 1.0, 0.001); + + v = cbm_normalized_levenshtein("", ""); + ASSERT_FLOAT_EQ(v, 1.0, 0.001); + + v = cbm_normalized_levenshtein("api/orders", "api/order"); + ASSERT(v >= 0.88 && v <= 0.92); + + v = cbm_normalized_levenshtein("/api/v1/items", "/api/v2/items"); + ASSERT(v >= 0.90 && v <= 0.94); + + v = cbm_normalized_levenshtein("completely", "different"); + ASSERT(v >= 0.0 && v <= 0.4); + + PASS(); +} + +TEST(httplink_ngram_overlap) { + double v; + + v = cbm_ngram_overlap("api/orders", "api/orders", 3); + ASSERT_FLOAT_EQ(v, 1.0, 0.001); + + v = cbm_ngram_overlap("api/orders", "api/order", 3); + ASSERT(v >= 0.8 && v <= 1.0); + + v = cbm_ngram_overlap("abcdef", "ghijkl", 3); + ASSERT_FLOAT_EQ(v, 0.0, 0.001); + + v = cbm_ngram_overlap("ab", "cd", 3); + ASSERT_FLOAT_EQ(v, 0.0, 0.001); + + PASS(); +} + +TEST(httplink_confidence_band) { + ASSERT_STR_EQ(cbm_confidence_band(0.95), "high"); + ASSERT_STR_EQ(cbm_confidence_band(0.70), "high"); + ASSERT_STR_EQ(cbm_confidence_band(0.69), "medium"); + ASSERT_STR_EQ(cbm_confidence_band(0.45), "medium"); + ASSERT_STR_EQ(cbm_confidence_band(0.44), "speculative"); + ASSERT_STR_EQ(cbm_confidence_band(0.25), "speculative"); + ASSERT_STR_EQ(cbm_confidence_band(0.24), ""); + ASSERT_STR_EQ(cbm_confidence_band(0.0), ""); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Path matching tests (port of httplink_test.go path tests) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_normalize_path) { + ASSERT_STR_EQ(cbm_normalize_path("/api/orders/"), "/api/orders"); + ASSERT_STR_EQ(cbm_normalize_path("/api/orders"), "/api/orders"); + ASSERT_STR_EQ(cbm_normalize_path("/api/orders/:id"), "/api/orders/*"); + ASSERT_STR_EQ(cbm_normalize_path("/api/orders/{order_id}"), "/api/orders/*"); + ASSERT_STR_EQ(cbm_normalize_path("/API/Orders"), "/api/orders"); + ASSERT_STR_EQ(cbm_normalize_path("/api/:version/items/:id"), "/api/*/items/*"); + ASSERT_STR_EQ(cbm_normalize_path("/api/{version}/items/{id}"), "/api/*/items/*"); + ASSERT_STR_EQ(cbm_normalize_path("/"), ""); + ASSERT_STR_EQ(cbm_normalize_path(""), ""); + PASS(); +} + +TEST(httplink_paths_match) { + /* Exact match */ + ASSERT_TRUE(cbm_paths_match("/api/orders", "/api/orders")); + ASSERT_TRUE(cbm_paths_match("/api/orders/", "/api/orders")); + + /* Case insensitive */ + ASSERT_TRUE(cbm_paths_match("/API/Orders", "/api/orders")); + + /* Suffix match */ + ASSERT_TRUE(cbm_paths_match("https://example.com/api/orders", "/api/orders")); + + /* Wildcard params */ + ASSERT_TRUE(cbm_paths_match("/api/orders/:id", "/api/orders/{order_id}")); + ASSERT_TRUE(cbm_paths_match("/api/orders/123", "/api/orders/:id")); + + /* Segment wildcard */ + ASSERT_TRUE(cbm_paths_match("/api/:version/items", "/api/v1/items")); + + /* Different lengths */ + ASSERT_FALSE(cbm_paths_match("/api/orders", "/api/orders/detail")); + ASSERT_FALSE(cbm_paths_match("/api", "/api/orders")); + + /* Both wildcards */ + ASSERT_TRUE(cbm_paths_match("/api/*/items", "/api/*/items")); + + /* No match */ + ASSERT_FALSE(cbm_paths_match("/api/users", "/api/orders")); + + PASS(); +} + +TEST(httplink_paths_match_suffix) { + ASSERT_TRUE(cbm_paths_match("/host/prefix/api/orders", "/api/orders")); + PASS(); +} + +TEST(httplink_path_match_score) { + double v; + + /* Exact matches */ + v = cbm_path_match_score("/api/orders", "/api/orders"); + ASSERT(v >= 0.78 && v <= 0.82); + + v = cbm_path_match_score("/integrate", "/integrate"); + ASSERT(v >= 0.60 && v <= 0.67); + + v = cbm_path_match_score("/api/v1/orders/items", "/api/v1/orders/items"); + ASSERT(v >= 0.93 && v <= 0.96); + + /* URL with scheme+host — normalizes to exact match after stripping host */ + v = cbm_path_match_score("https://host/api/orders", "/api/orders"); + ASSERT(v >= 0.78 && v <= 0.82); + + /* Numeric IDs normalized to wildcard */ + v = cbm_path_match_score("/api/orders/123", "/api/orders/:id"); + ASSERT(v >= 0.90 && v <= 0.96); + + /* No match */ + v = cbm_path_match_score("/api/users", "/api/orders"); + ASSERT_FLOAT_EQ(v, 0.0, 0.001); + + v = cbm_path_match_score("/", "/api/orders"); + ASSERT_FLOAT_EQ(v, 0.0, 0.001); + + v = cbm_path_match_score("", "/api/orders"); + ASSERT_FLOAT_EQ(v, 0.0, 0.001); + + PASS(); +} + +TEST(httplink_same_service) { + /* Same dir */ + ASSERT_TRUE(cbm_same_service("a.b.c.mod.Func1", "a.b.c.mod.Func2")); + /* Different dir */ + ASSERT_FALSE(cbm_same_service("a.b.c.mod.Func1", "a.b.x.mod.Func2")); + /* Same deep dir */ + ASSERT_TRUE(cbm_same_service("a.b.c.d.mod.Func", "a.b.c.d.mod.Other")); + /* Different deep dir */ + ASSERT_FALSE(cbm_same_service("a.b.c.d.mod.Func", "a.b.c.e.mod.Other")); + /* Too few segments */ + ASSERT_FALSE(cbm_same_service("short.x", "short.y")); + ASSERT_FALSE(cbm_same_service("a.b", "a.b")); + /* 3 segments: dir="a" */ + ASSERT_TRUE(cbm_same_service("a.b.c", "a.b.c")); + ASSERT_FALSE(cbm_same_service("a.b.c", "x.b.c")); + /* Realistic multi-service */ + ASSERT_TRUE(cbm_same_service("myapp.docker-images.cloud-runs.order-service.main.Func", + "myapp.docker-images.cloud-runs.order-service.handlers.Other")); + ASSERT_FALSE( + cbm_same_service("myapp.docker-images.cloud-runs.order-service.main.Func", + "myapp.docker-images.cloud-runs.notification-service.main.health_check")); + ASSERT_TRUE(cbm_same_service("myapp.docker-images.cloud-runs.svcA.sub.mod.Func", + "myapp.docker-images.cloud-runs.svcA.sub.mod.Other")); + ASSERT_FALSE(cbm_same_service("myapp.docker-images.cloud-runs.svcA.sub.mod.Func", + "myapp.docker-images.cloud-runs.svcB.sub.mod.Other")); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * URL extraction tests + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_extract_url_paths) { + char *paths[16]; + int n; + + n = cbm_extract_url_paths("URL = \"https://example.com/api/orders\"", paths, 16); + ASSERT_EQ(n, 1); + for (int i = 0; i < n; i++) + free(paths[i]); + + n = cbm_extract_url_paths("fetch(\"http://host/api/v1/items\")", paths, 16); + ASSERT_EQ(n, 1); + for (int i = 0; i < n; i++) + free(paths[i]); + + n = cbm_extract_url_paths("path = \"/api/orders\"", paths, 16); + ASSERT_EQ(n, 1); + for (int i = 0; i < n; i++) + free(paths[i]); + + n = cbm_extract_url_paths("no urls here", paths, 16); + ASSERT_EQ(n, 0); + + n = cbm_extract_url_paths("both = \"https://a.com/api/x\" and \"/api/y\"", paths, 16); + ASSERT_EQ(n, 2); + for (int i = 0; i < n; i++) + free(paths[i]); + + PASS(); +} + +TEST(httplink_extract_json_string_paths) { + char *paths[16]; + int n; + + n = cbm_extract_json_string_paths( + "BODY = '{\"target\": \"https://api.internal.com/api/orders\", \"method\": \"POST\"}'", + paths, 16); + ASSERT_EQ(n, 1); + for (int i = 0; i < n; i++) + free(paths[i]); + + n = cbm_extract_json_string_paths( + "CONFIG = {\"endpoint\": \"/api/v1/process\", \"timeout\": 30}", paths, 16); + ASSERT_EQ(n, 1); + for (int i = 0; i < n; i++) + free(paths[i]); + + n = cbm_extract_json_string_paths("plain string without json", paths, 16); + ASSERT_EQ(n, 0); + + n = cbm_extract_json_string_paths( + "{\"services\": [{\"url\": \"https://svc.example.com/api/health\"}]}", paths, 16); + ASSERT_EQ(n, 1); + for (int i = 0; i < n; i++) + free(paths[i]); + + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Route extraction: Python + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_extract_python_routes) { + const char *decs[] = {"@app.post(\"/api/orders\")"}; + cbm_route_handler_t routes[4]; + int n = cbm_extract_python_routes("create_order", "proj.api.routes.create_order", decs, 1, + routes, 4); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].path, "/api/orders"); + ASSERT_STR_EQ(routes[0].method, "POST"); + ASSERT_STR_EQ(routes[0].qualified_name, "proj.api.routes.create_order"); + PASS(); +} + +TEST(httplink_extract_python_routes_multiple) { + const char *decs[] = {"@router.get(\"/api/items/{item_id}\")", "@router.post(\"/api/items\")"}; + cbm_route_handler_t routes[4]; + int n = cbm_extract_python_routes("handler", "proj.api.handler", decs, 2, routes, 4); + ASSERT_EQ(n, 2); + PASS(); +} + +TEST(httplink_extract_python_routes_no_decorators) { + cbm_route_handler_t routes[4]; + int n = cbm_extract_python_routes("helper", "proj.utils.helper", NULL, 0, routes, 4); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(httplink_extract_python_ws_routes) { + const char *decs[] = {"@app.websocket(\"/ws/chat\")"}; + cbm_route_handler_t routes[4]; + int n = cbm_extract_python_routes("ws_handler", "proj.api.ws_handler", decs, 1, routes, 4); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].path, "/ws/chat"); + ASSERT_STR_EQ(routes[0].method, "WS"); + ASSERT_STR_EQ(routes[0].protocol, "ws"); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Route extraction: Go gin/chi + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_extract_go_routes) { + const char *source = "\tr.POST(\"/api/orders\", h.CreateOrder)\n" + "\tr.GET(\"/api/orders/:id\", h.GetOrder)\n"; + cbm_route_handler_t routes[8]; + int n = cbm_extract_go_routes("RegisterRoutes", "proj.api.RegisterRoutes", source, routes, 8); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(routes[0].path, "/api/orders"); + ASSERT_STR_EQ(routes[0].method, "POST"); + ASSERT_STR_EQ(routes[1].path, "/api/orders/:id"); + PASS(); +} + +TEST(httplink_chi_prefix) { + const char *source = "func SetupRoutes(r chi.Router) {\n" + "\tr.Route(\"/api\", func(r chi.Router) {\n" + "\t\tr.Get(\"/health\", healthHandler)\n" + "\t\tr.Route(\"/users\", func(r chi.Router) {\n" + "\t\t\tr.Get(\"/\", listUsers)\n" + "\t\t\tr.Post(\"/{id}\", updateUser)\n" + "\t\t})\n" + "\t})\n" + "}\n"; + cbm_route_handler_t routes[8]; + int n = cbm_extract_go_routes("SetupRoutes", "proj.SetupRoutes", source, routes, 8); + ASSERT_EQ(n, 3); + + /* Verify all routes have /api prefix */ + /* Verify all routes have /api prefix */ + bool found_health = false; + for (int i = 0; i < n; i++) { + if (strcmp(routes[i].path, "/api/health") == 0 && strcmp(routes[i].method, "GET") == 0) + found_health = true; + } + ASSERT_TRUE(found_health); + PASS(); +} + +TEST(httplink_chi_prefix_mixed_with_gin) { + const char *source = "func RegisterRoutes(r *gin.RouterGroup) {\n" + "\torders := r.Group(\"/orders\")\n" + "\torders.GET(\"/:id\", getOrder)\n" + "\torders.POST(\"\", createOrder)\n" + "}\n"; + cbm_route_handler_t routes[8]; + int n = cbm_extract_go_routes("RegisterRoutes", "proj.RegisterRoutes", source, routes, 8); + ASSERT_EQ(n, 2); + /* Both should have /orders prefix from gin group */ + for (int i = 0; i < n; i++) { + ASSERT(strncmp(routes[i].path, "/orders", 7) == 0); + } + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Route extraction: Java Spring + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_extract_spring_ws_routes) { + const char *decs[] = {"@MessageMapping(\"/chat\")"}; + cbm_route_handler_t routes[4]; + int n = + cbm_extract_java_routes("handleChat", "proj.ChatController.handleChat", decs, 1, routes, 4); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].path, "/chat"); + ASSERT_STR_EQ(routes[0].method, "WS"); + ASSERT_STR_EQ(routes[0].protocol, "ws"); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Route extraction: Ktor + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_extract_ktor_ws_routes) { + const char *source = "\twebSocket(\"/chat\") {\n" + "\t\tfor (frame in incoming) {\n" + "\t\t\tsend(frame)\n" + "\t\t}\n" + "\t}\n" + "\tget(\"/api/health\") {\n" + "\t\tcall.respond(\"ok\")\n" + "\t}\n"; + cbm_route_handler_t routes[8]; + int n = cbm_extract_ktor_routes("configureRouting", "proj.Routing.configureRouting", source, + routes, 8); + ASSERT_EQ(n, 2); + + bool ws_found = false, http_found = false; + for (int i = 0; i < n; i++) { + if (strcmp(routes[i].protocol, "ws") == 0 && strcmp(routes[i].path, "/chat") == 0 && + strcmp(routes[i].method, "WS") == 0) + ws_found = true; + if (strcmp(routes[i].path, "/api/health") == 0 && strcmp(routes[i].method, "GET") == 0) + http_found = true; + } + ASSERT_TRUE(ws_found); + ASSERT_TRUE(http_found); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Route extraction: Express.js (with allowlist filtering) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_express_route_filtering) { + cbm_route_handler_t routes[4]; + int n; + + /* Should match (allowlisted receivers) */ + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", + "app.get('/api/users', handler)", routes, 4); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].method, "GET"); + ASSERT_STR_EQ(routes[0].path, "/api/users"); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", + "router.post('/orders', handler)", routes, 4); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].method, "POST"); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", + "server.put('/items', handler)", routes, 4); + ASSERT_EQ(n, 1); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", + "api.delete('/users/:id', handler)", routes, 4); + ASSERT_EQ(n, 1); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", + "routes.patch('/items/:id', handler)", routes, 4); + ASSERT_EQ(n, 1); + + /* Should NOT match (not in allowlist) */ + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", "req.get('Content-Type')", + routes, 4); + ASSERT_EQ(n, 0); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", "res.get('key')", routes, 4); + ASSERT_EQ(n, 0); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", "this.get('property')", routes, + 4); + ASSERT_EQ(n, 0); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", "map.get('key')", routes, 4); + ASSERT_EQ(n, 0); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", "model.delete('record')", + routes, 4); + ASSERT_EQ(n, 0); + + n = cbm_extract_express_routes("testFunc", "proj.test.testFunc", "params.get('id')", routes, 4); + ASSERT_EQ(n, 0); + + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Detection tests + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_detect_protocol) { + ASSERT_STR_EQ(cbm_detect_protocol("err := websocket.Upgrade(w, r, nil, 1024, 1024)"), "ws"); + ASSERT_STR_EQ(cbm_detect_protocol("conn, err := websocket.Accept(w, r, nil)"), "ws"); + ASSERT_STR_EQ(cbm_detect_protocol("conn, err := upgrader.Upgrade(w, r, nil)"), "ws"); + ASSERT_STR_EQ(cbm_detect_protocol("ws.on(\"connection\", func)"), "ws"); + ASSERT_STR_EQ(cbm_detect_protocol("io.on(\"connection\", handler)"), "ws"); + ASSERT_STR_EQ(cbm_detect_protocol("w.Header().Set(\"Content-Type\", \"text/event-stream\")"), + "sse"); + ASSERT_STR_EQ(cbm_detect_protocol("return EventSourceResponse(generate())"), "sse"); + ASSERT_STR_EQ(cbm_detect_protocol("SseEmitter emitter = new SseEmitter()"), "sse"); + ASSERT_STR_EQ(cbm_detect_protocol("ServerSentEvent event = ServerSentEvent.builder()"), "sse"); + ASSERT_STR_EQ(cbm_detect_protocol("return json.Marshal(result)"), ""); + ASSERT_STR_EQ(cbm_detect_protocol(""), ""); + PASS(); +} + +TEST(httplink_is_test_node) { + ASSERT_FALSE(cbm_is_test_node_fp("src/routes/api.js", false)); + ASSERT_TRUE(cbm_is_test_node_fp("test/app.get.js", false)); + ASSERT_TRUE(cbm_is_test_node_fp("__tests__/routes.test.ts", false)); + ASSERT_TRUE(cbm_is_test_node_fp("src/routes/api.js", true)); + ASSERT_FALSE(cbm_is_test_node_fp("lib/router/index.js", false)); + ASSERT_TRUE(cbm_is_test_node_fp("tests/fixtures/server.js", false)); + ASSERT_FALSE(cbm_is_test_node_fp("app/controllers/orders_controller.rb", false)); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Config tests (port of config_test.go) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_is_path_excluded) { + const char *paths[] = {"/health", "/debug", "/internal/status"}; + ASSERT_TRUE(cbm_is_path_excluded("/health", paths, 3)); + ASSERT_TRUE(cbm_is_path_excluded("/health/", paths, 3)); + ASSERT_TRUE(cbm_is_path_excluded("/HEALTH", paths, 3)); + ASSERT_TRUE(cbm_is_path_excluded("/debug", paths, 3)); + ASSERT_TRUE(cbm_is_path_excluded("/internal/status", paths, 3)); + ASSERT_FALSE(cbm_is_path_excluded("/api/orders", paths, 3)); + ASSERT_FALSE(cbm_is_path_excluded("/healthcheck", paths, 3)); + PASS(); +} + +TEST(httplink_default_exclude_paths) { + /* Verify default exclude paths include common health/debug endpoints */ + ASSERT(cbm_default_exclude_paths_count >= 9); + /* Verify /health is in the list */ + bool found = false; + for (int i = 0; i < cbm_default_exclude_paths_count; i++) { + if (strcmp(cbm_default_exclude_paths[i], "/health") == 0) { + found = true; + break; + } + } + ASSERT_TRUE(found); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * YAML config tests (port of config_test.go — YAML-dependent tests) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_load_config_default) { + /* Nonexistent path → defaults */ + cbm_httplink_config_t cfg = cbm_httplink_load_config("/nonexistent/path"); + ASSERT_FLOAT_EQ(cbm_httplink_effective_min_confidence(&cfg), 0.25, 0.001); + ASSERT_TRUE(cbm_httplink_effective_fuzzy_matching(&cfg)); + + const char *paths[64]; + int count = cbm_httplink_all_exclude_paths(&cfg, paths, 64); + ASSERT_EQ(count, cbm_default_exclude_paths_count); + + cbm_httplink_config_free(&cfg); + PASS(); +} + +TEST(httplink_load_config_from_file) { + /* Create temp dir with .cgrconfig */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/httplink-cfg-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char cfgpath[512]; + snprintf(cfgpath, sizeof(cfgpath), "%s/.cgrconfig", tmpdir); + + FILE *f = fopen(cfgpath, "w"); + if (!f) { + cbm_rmdir(tmpdir); + FAIL("cannot write .cgrconfig"); + } + fprintf(f, "\n" + "http_linker:\n" + " exclude_paths:\n" + " - /debug\n" + " - /internal/status\n" + " min_confidence: 0.5\n" + " fuzzy_matching: false\n"); + fclose(f); + + cbm_httplink_config_t cfg = cbm_httplink_load_config(tmpdir); + ASSERT_FLOAT_EQ(cbm_httplink_effective_min_confidence(&cfg), 0.5, 0.001); + ASSERT_FALSE(cbm_httplink_effective_fuzzy_matching(&cfg)); + + const char *paths[64]; + int count = cbm_httplink_all_exclude_paths(&cfg, paths, 64); + int expected = cbm_default_exclude_paths_count + 2; + ASSERT_EQ(count, expected); + + cbm_httplink_config_free(&cfg); + + /* Cleanup */ + cbm_unlink(cfgpath); + cbm_rmdir(tmpdir); + PASS(); +} + +TEST(httplink_load_config_invalid_yaml) { + /* Invalid YAML → fallback to defaults */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/httplink-bad-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char cfgpath[512]; + snprintf(cfgpath, sizeof(cfgpath), "%s/.cgrconfig", tmpdir); + + FILE *f = fopen(cfgpath, "w"); + if (!f) { + cbm_rmdir(tmpdir); + FAIL("cannot write .cgrconfig"); + } + fprintf(f, "not: [valid: yaml"); + fclose(f); + + cbm_httplink_config_t cfg = cbm_httplink_load_config(tmpdir); + /* Should fall back to defaults */ + ASSERT_FLOAT_EQ(cbm_httplink_effective_min_confidence(&cfg), 0.25, 0.001); + + cbm_httplink_config_free(&cfg); + + /* Cleanup */ + cbm_unlink(cfgpath); + cbm_rmdir(tmpdir); + PASS(); +} + +TEST(httplink_all_exclude_paths_merge) { + /* User-configured paths should be appended after defaults */ + cbm_httplink_config_t cfg = cbm_httplink_default_config(); + cfg.exclude_paths = calloc(2, sizeof(char *)); + cfg.exclude_paths[0] = cbm_strdup("/custom1"); + cfg.exclude_paths[1] = cbm_strdup("/custom2"); + cfg.exclude_path_count = 2; + + const char *paths[64]; + int count = cbm_httplink_all_exclude_paths(&cfg, paths, 64); + int expected = cbm_default_exclude_paths_count + 2; + ASSERT_EQ(count, expected); + + /* Verify defaults are first */ + for (int i = 0; i < cbm_default_exclude_paths_count; i++) { + ASSERT_STR_EQ(paths[i], cbm_default_exclude_paths[i]); + } + + /* Verify custom paths are appended */ + ASSERT_STR_EQ(paths[cbm_default_exclude_paths_count], "/custom1"); + ASSERT_STR_EQ(paths[cbm_default_exclude_paths_count + 1], "/custom2"); + + cbm_httplink_config_free(&cfg); + PASS(); +} + +TEST(httplink_is_path_excluded_skips_null_entries) { + const char *paths[] = {NULL, "/health", NULL}; + + ASSERT_TRUE(cbm_is_path_excluded("/health", paths, 3)); + ASSERT_FALSE(cbm_is_path_excluded("/ready", paths, 3)); + ASSERT_FALSE(cbm_is_path_excluded("/health", NULL, 3)); + + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Langparity tests (port of langparity_test.go) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_http_client_keywords_all_languages) { + /* Each language should have at least one keyword in the list */ + typedef struct { + const char *lang; + const char **keywords; + int nkw; + } lang_kw_t; + + const char *py_kw[] = {"requests.get", "httpx.", "aiohttp."}; + const char *go_kw[] = {"http.Get", "http.Post", "http.NewRequest"}; + const char *js_kw[] = {"fetch(", "axios."}; + const char *java_kw[] = {"HttpClient", "RestTemplate"}; + const char *rust_kw[] = {"reqwest::", "hyper::"}; + + lang_kw_t langs[] = { + {"Python", py_kw, 3}, {"Go", go_kw, 3}, {"JavaScript", js_kw, 2}, + {"Java", java_kw, 2}, {"Rust", rust_kw, 2}, + }; + + for (int l = 0; l < 5; l++) { + bool found = false; + for (int k = 0; k < langs[l].nkw && !found; k++) { + for (int i = 0; i < cbm_http_client_keywords_count; i++) { + if (strstr(cbm_http_client_keywords[i], langs[l].keywords[k]) || + strcmp(cbm_http_client_keywords[i], langs[l].keywords[k]) == 0) { + found = true; + break; + } + } + } + if (!found) { + printf(" FAIL: no HTTP client keywords for %s\n", langs[l].lang); + return 1; + } + } + PASS(); +} + +TEST(httplink_route_extraction_negative_cases) { + cbm_route_handler_t routes[8]; + int n; + const char *sources[] = { + "func processOrder(order Order) error {\n\treturn nil\n}\n", + "function calculate(x, y) {\n\treturn x + y;\n}\n", + "def transform_data(data):\n return data.upper()\n", + }; + + for (int i = 0; i < 3; i++) { + /* Python */ + n = cbm_extract_python_routes("fn", "proj.fn", NULL, 0, routes, 8); + ASSERT_EQ(n, 0); + + /* Go */ + n = cbm_extract_go_routes("fn", "proj.fn", sources[i], routes, 8); + ASSERT_EQ(n, 0); + + /* Express */ + n = cbm_extract_express_routes("fn", "proj.fn", sources[i], routes, 8); + ASSERT_EQ(n, 0); + + /* Laravel */ + n = cbm_extract_laravel_routes("fn", "proj.fn", sources[i], routes, 8); + ASSERT_EQ(n, 0); + } + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Read source lines tests + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(httplink_read_source_lines) { + /* Create temp dir with test file */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/httplink-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + printf(" SKIP: cbm_mkdtemp failed\n"); + return -1; + } + + char fpath[512]; + snprintf(fpath, sizeof(fpath), "%s/test.go", tmpdir); + FILE *f = cbm_fopen(fpath, "wb"); + if (!f) { + printf(" SKIP: cannot write\n"); + return -1; + } + fprintf(f, "line1\nline2\nline3\nline4\nline5\n"); + fclose(f); + + char *result = cbm_read_source_lines_disk(tmpdir, "test.go", 2, 4); + ASSERT_NOT_NULL(result); + ASSERT_STR_EQ(result, "line2\nline3\nline4"); + free(result); + + /* Cleanup */ + cbm_unlink(fpath); + cbm_rmdir(tmpdir); + PASS(); +} + +TEST(httplink_read_source_lines_missing_file) { + char *result = cbm_read_source_lines_disk("/nonexistent", "missing.go", 1, 10); + ASSERT_NULL(result); + PASS(); +} + +TEST(httplink_read_source_file_limited) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/httplink-full-test-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + printf(" SKIP: cbm_mkdtemp failed\n"); + return -1; + } + + char fpath[512]; + snprintf(fpath, sizeof(fpath), "%s/app.js", tmpdir); + FILE *f = cbm_fopen(fpath, "wb"); + if (!f) { + printf(" SKIP: cannot write\n"); + cbm_rmdir(tmpdir); + return -1; + } + fprintf(f, "const app = 1;\n"); + fclose(f); + + size_t len = 0; + char *source = + cbm_read_source_file_disk_limited(tmpdir, "app.js", CBM_HTTPLINK_FULL_SOURCE_MAX_BYTES, &len); + ASSERT_NOT_NULL(source); + ASSERT_STR_EQ(source, "const app = 1;\n"); + ASSERT_EQ((int)len, 15); + free(source); + + len = 123; + source = cbm_read_source_file_disk_limited(tmpdir, "app.js", 4, &len); + ASSERT_NULL(source); + ASSERT_EQ((int)len, 0); + + cbm_unlink(fpath); + cbm_rmdir(tmpdir); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Integration tests with store (port of Linker tests) + * These test the full pipeline: create nodes → run linker → verify edges + * ═══════════════════════════════════════════════════════════════════ */ + +/* ── Linker integration: route nodes created (simplified) ──────── */ + +TEST(httplink_linker_route_nodes) { + /* Test route extraction from Python decorators (no store needed). */ + const char *decs[] = {"@app.post(\"/api/orders\")"}; + cbm_route_handler_t routes[4]; + int n = cbm_extract_python_routes("create_order", "testproj.handler.routes.create_order", decs, + 1, routes, 4); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].path, "/api/orders"); + ASSERT_STR_EQ(routes[0].method, "POST"); + PASS(); +} + +/* ── Linker integration: same-service skip ─────────────────────── */ + +TEST(httplink_linker_same_service_skip) { + /* Both caller and handler in same service → no link */ + ASSERT_TRUE(cbm_same_service("testproj.cat.sub.svcA.internal.client", + "testproj.cat.sub.svcA.internal.handle_orders")); + PASS(); +} + +/* ── Laravel path filter: reject $ and : in matched paths ──────── */ + +TEST(httplink_laravel_path_filter) { + cbm_route_handler_t routes[8]; + int n; + + /* Cache key patterns should be filtered (contain $ or :) */ + n = cbm_extract_laravel_routes("fn", "proj.fn", + "Cache::get('article:{$this->id}:image')", routes, 8); + ASSERT_EQ(n, 0); + + n = cbm_extract_laravel_routes("fn", "proj.fn", + "Route::get(\"cache:$key\", fn() => null)", routes, 8); + ASSERT_EQ(n, 0); + + /* Valid routes should still pass (Laravel uses {param} not $param) */ + n = cbm_extract_laravel_routes("fn", "proj.fn", + "Route::get('/api/users/{id}', 'UserController@show')", routes, + 8); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].path, "/api/users/{id}"); + + /* Route with no special chars also passes */ + n = cbm_extract_laravel_routes("fn", "proj.fn", + "Route::post('/api/login', 'AuthController@login')", routes, 8); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(routes[0].path, "/api/login"); + + PASS(); +} + +/* ── Laravel module-level route extraction ─────────────────────── */ + +TEST(httplink_laravel_module_level_routes) { + const char *source = " #include #include #include #include #include #include +#include #include #include @@ -34,6 +39,7 @@ static char g_tmpdir[256]; static char g_repodir[512]; static char g_dbpath[512]; static cbm_mcp_server_t *g_srv = NULL; +static cbm_config_t *g_cfg = NULL; static char *g_project = NULL; /* Baseline counts after full index */ @@ -46,6 +52,58 @@ static int g_full_imports = 0; static size_t g_rss_before_full = 0; static double g_full_index_ms = 0; +enum { + INCR_ACCURACY_NODE_TOLERANCE = 2, + INCR_ACCURACY_EDGE_TOLERANCE = 50, + INCR_ACCURACY_CALL_TOLERANCE = 2, + INCR_FORMATTER_MAX_FILES = 50, + /* Measured FastAPI 0.99.1 production-build budgets: x86 peaks around + * 2050-2072 MiB; ARM/16 KiB-page runners peak around 2385 MiB. */ + INCR_FULL_INDEX_BASE_MAX_RSS_DELTA_MB = 2304, + INCR_FULL_INDEX_LARGE_PAGE_MAX_RSS_DELTA_MB = 2816, + INCR_LARGE_PAGE_MIN_BYTES = 16384, + INCR_INSTRUMENTED_TIMEOUT_MULTIPLIER = 4, +}; + +static const char *INCR_TEST_ARTIFACT_ENV = "CBM_TEST_ARTIFACT_DIR"; +static const char *INCR_TEST_FASTAPI_REPO_ENV = "CBM_TEST_FASTAPI_REPO"; +static const char *INCR_TEST_FASTAPI_CACHE_ENV = "CBM_TEST_FASTAPI_CACHE"; +static const char *INCR_TEST_FASTAPI_URL = "https://github.com/fastapi/fastapi.git"; +static const char *INCR_TEST_FASTAPI_TAG = "0.99.1"; +static const char *INCR_TEST_FASTAPI_COMMIT = "dd4e78ca7b09abdf0d4646fe4697316c021a8b2e"; +static const char *INCR_TEST_FASTAPI_DEFAULT_CACHE_NAME = "cbm-test-fastapi-0.99.1-cache"; + +#ifndef __has_feature +#define __has_feature(x) 0 +#endif + +static bool incr_memory_instrumentation_active(void) { +#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__) || \ + __has_feature(address_sanitizer) || __has_feature(thread_sanitizer) + return true; +#else + const char *scribble = getenv("MallocScribble"); + const char *pre_scribble = getenv("MallocPreScribble"); + const char *guard_malloc = getenv("DYLD_INSERT_LIBRARIES"); + return (scribble && strcmp(scribble, "1") == 0) || + (pre_scribble && strcmp(pre_scribble, "1") == 0) || + (guard_malloc && strstr(guard_malloc, "libgmalloc") != NULL); +#endif +} + +static int incr_full_index_rss_limit_mb(void) { + int rss_limit_mb = INCR_FULL_INDEX_BASE_MAX_RSS_DELTA_MB; +#ifndef _WIN32 + if (sysconf(_SC_PAGESIZE) >= INCR_LARGE_PAGE_MIN_BYTES) { + rss_limit_mb = INCR_FULL_INDEX_LARGE_PAGE_MAX_RSS_DELTA_MB; + } +#endif +#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) + rss_limit_mb = INCR_FULL_INDEX_LARGE_PAGE_MAX_RSS_DELTA_MB; +#endif + return rss_limit_mb; +} + /* ── Helpers ──────────────────────────────────────────────────────── */ static double now_ms(void) { @@ -156,6 +214,52 @@ static cbm_store_t *open_store(void) { return cbm_store_open_path_existing(g_dbpath); } +static int dump_current_store_to_file(const char *dest_path) { + cbm_store_t *s = open_store(); + if (!s) { + return CBM_STORE_ERR; + } + int rc = cbm_store_dump_to_file(s, dest_path); + cbm_store_close(s); + return rc; +} + +static int dump_store_file_to_file(const char *src_path, const char *dest_path) { + cbm_store_t *s = cbm_store_open_path(src_path); + if (!s) { + return CBM_STORE_ERR; + } + int rc = cbm_store_dump_to_file(s, dest_path); + cbm_store_close(s); + return rc; +} + +static void preserve_accuracy_artifacts(const char *incr_snapshot_path, const char *reason) { + char artifact_dir[CBM_SZ_512]; + const char *dir = + cbm_safe_getenv(INCR_TEST_ARTIFACT_ENV, artifact_dir, sizeof(artifact_dir), cbm_tmpdir()); + if (!dir || dir[0] == '\0') { + dir = cbm_tmpdir(); + } + + char incr_out[CBM_SZ_1K]; + char full_out[CBM_SZ_1K]; + int pid = (int)getpid(); + int n1 = snprintf(incr_out, sizeof(incr_out), "%s/cbm-incr-accuracy-%d-incremental.db", dir, + pid); + int n2 = snprintf(full_out, sizeof(full_out), "%s/cbm-incr-accuracy-%d-full.db", dir, pid); + if (n1 <= 0 || n2 <= 0 || (size_t)n1 >= sizeof(incr_out) || + (size_t)n2 >= sizeof(full_out)) { + printf(" [accuracy:artifacts] skipped: artifact path too long\n"); + return; + } + + int incr_rc = dump_store_file_to_file(incr_snapshot_path, incr_out); + int full_rc = dump_current_store_to_file(full_out); + printf(" [accuracy:artifacts] reason=%s incremental=%s rc=%d full=%s rc=%d\n", + reason ? reason : "canonical-diff", incr_out, incr_rc, full_out, full_rc); +} + static int get_node_count(void) { cbm_store_t *s = open_store(); if (!s) @@ -183,6 +287,114 @@ static int get_edge_count_by_type(const char *type) { return c; } +static const char *const k_accuracy_edge_types[] = { + "CALLS", + "IMPORTS", + "DEFINES", + "CONTAINS_FILE", + "CONTAINS_FOLDER", + "HAS_BRANCH", + "DEFINES_METHOD", + "MEMBER_OF", + "HAS_FIELD", + "HANDLES", + "HTTP_CALLS", + "ASYNC_CALLS", + "DATA_FLOWS", + "INFRA_MAPS", + "CONFIGURES", + "DEPENDS_ON", + "FILE_CHANGES_WITH", + "SIMILAR_TO", + "SEMANTICALLY_RELATED", + "TESTS", + "TESTS_FILE", + "USAGE", + "THROWS", + "RAISES", + "WRITES", + "READS", + "INHERITS", + "DECORATES", + "IMPLEMENTS", + "EMITS", + "LISTENS_ON", + "GRPC_CALLS", + "GRAPHQL_CALLS", + "TRPC_CALLS", +}; + +enum { ACCURACY_EDGE_TYPE_COUNT = sizeof(k_accuracy_edge_types) / sizeof(k_accuracy_edge_types[0]) }; + +static int accuracy_edge_type_count(void) { + return ACCURACY_EDGE_TYPE_COUNT; +} + +static void capture_accuracy_edge_counts(int counts[ACCURACY_EDGE_TYPE_COUNT]) { + int n = accuracy_edge_type_count(); + for (int i = 0; i < n; i++) { + counts[i] = get_edge_count_by_type(k_accuracy_edge_types[i]); + } +} + +static void print_accuracy_edge_diff(const int incr_counts[ACCURACY_EDGE_TYPE_COUNT], + const int full_counts[ACCURACY_EDGE_TYPE_COUNT]) { + int n = accuracy_edge_type_count(); + printf(" [accuracy:edge-types] type incr full delta\n"); + for (int i = 0; i < n; i++) { + int delta = incr_counts[i] - full_counts[i]; + if (delta != 0) { + printf(" [accuracy:edge-types] %s %d %d %+d\n", k_accuracy_edge_types[i], + incr_counts[i], full_counts[i], delta); + } + } +} + +typedef struct { + int total; + int handler; + int prefix_bridge; + int infra_match; + int empty; + int other; +} handle_breakdown_t; + +static handle_breakdown_t capture_handle_breakdown(void) { + handle_breakdown_t b = {0}; + cbm_store_t *s = open_store(); + if (!s) { + return b; + } + cbm_edge_t *edges = NULL; + int count = 0; + if (cbm_store_find_edges_by_type(s, g_project, "HANDLES", &edges, &count) == CBM_STORE_OK) { + b.total = count; + for (int i = 0; i < count; i++) { + const char *props = edges[i].properties_json ? edges[i].properties_json : "{}"; + if (strstr(props, "\"source\":\"prefix_decorator_bridge\"")) { + b.prefix_bridge++; + } else if (strstr(props, "\"source\":\"infra_match\"")) { + b.infra_match++; + } else if (strstr(props, "\"handler\"")) { + b.handler++; + } else if (strcmp(props, "{}") == 0) { + b.empty++; + } else { + b.other++; + } + } + cbm_store_free_edges(edges, count); + } + cbm_store_close(s); + return b; +} + +static void print_handle_breakdown(const char *label, handle_breakdown_t b) { + printf(" [accuracy:handles:%s] total=%d handler=%d prefix_bridge=%d infra_match=%d " + "empty=%d other=%d\n", + label, b.total, b.handler, b.prefix_bridge, b.infra_match, b.empty, b.other); +} + static int has_function(const char *name_pattern) { char *resp = call_tool("search_graph", "{\"project\":\"%s\",\"label\":\"Function\",\"name_pattern\":\"%s\"}", @@ -200,6 +412,163 @@ static int count_by_label(const char *label) { return total; } +static int incr_join_path(char *out, size_t out_sz, const char *base, const char *rel) { + if (!out || out_sz == 0 || !base || !base[0] || !rel || !rel[0]) { + return -1; + } + int n = snprintf(out, out_sz, "%s/%s", base, rel); + return (n >= 0 && (size_t)n < out_sz) ? 0 : -1; +} + +static bool incr_shell_path_ok(const char *path) { + return path && path[0] && cbm_validate_shell_arg(path); +} + +static bool incr_fastapi_fixture_has_required_files(const char *repo) { + char path[CBM_SZ_1K]; + if (incr_join_path(path, sizeof(path), repo, "fastapi/applications.py") != 0 || + !cbm_file_exists(path)) { + return false; + } + if (incr_join_path(path, sizeof(path), repo, "tests/test_application.py") != 0 || + !cbm_file_exists(path)) { + return false; + } + if (incr_join_path(path, sizeof(path), repo, "docs/en/docs/release-notes.md") != 0 || + !cbm_file_exists(path)) { + return false; + } + return true; +} + +static bool incr_fastapi_fixture_at_expected_commit(const char *repo) { + if (!incr_shell_path_ok(repo)) { + return false; + } + char cmd[CBM_SZ_1K]; + int n = snprintf(cmd, sizeof(cmd), "git -C '%s' rev-parse --verify HEAD 2>/dev/null", repo); + if (n < 0 || (size_t)n >= sizeof(cmd)) { + return false; + } + FILE *fp = cbm_popen(cmd, "r"); + if (!fp) { + return false; + } + char head[CBM_SZ_128] = {0}; + bool ok = fgets(head, sizeof(head), fp) != NULL; + (void)cbm_pclose(fp); + if (!ok) { + return false; + } + head[strcspn(head, "\r\n")] = '\0'; + return strcmp(head, INCR_TEST_FASTAPI_COMMIT) == 0; +} + +static bool incr_fastapi_fixture_valid(const char *repo) { + return incr_fastapi_fixture_has_required_files(repo) && + incr_fastapi_fixture_at_expected_commit(repo); +} + +static int incr_clone_fastapi_fixture_from(const char *source) { + if (!incr_shell_path_ok(source) || !incr_shell_path_ok(g_repodir)) { + return -1; + } + char cmd[CBM_SZ_2K]; + int n = snprintf(cmd, sizeof(cmd), + "git clone --quiet --no-hardlinks '%s' '%s' 2>&1", source, + g_repodir); + if (n < 0 || (size_t)n >= sizeof(cmd)) { + return -1; + } + int rc = system(cmd); + if (rc != 0) { + return rc; + } + if (getenv("CI")) { + n = snprintf(cmd, sizeof(cmd), + "cd '%s' && git sparse-checkout set --no-cone '/*' '!/docs' '!/tests' " + "2>&1", + g_repodir); + if (n < 0 || (size_t)n >= sizeof(cmd)) { + return -1; + } + rc = system(cmd); + } + return rc; +} + +static int incr_clone_fastapi_fixture_from_network(const char *dest, bool sparse_on_ci) { + if (!incr_shell_path_ok(dest)) { + return -1; + } + char cmd[CBM_SZ_2K]; + int n = 0; + if (sparse_on_ci && getenv("CI")) { + n = snprintf(cmd, sizeof(cmd), + "git clone --depth=1 --branch %s --quiet --filter=blob:none --sparse " + "%s '%s' 2>&1 && cd '%s' && git sparse-checkout set --no-cone '/*' " + "'!/docs' '!/tests' 2>&1", + INCR_TEST_FASTAPI_TAG, INCR_TEST_FASTAPI_URL, dest, dest); + } else { + n = snprintf(cmd, sizeof(cmd), "git clone --depth=1 --branch %s --quiet %s '%s' 2>&1", + INCR_TEST_FASTAPI_TAG, INCR_TEST_FASTAPI_URL, dest); + } + if (n < 0 || (size_t)n >= sizeof(cmd)) { + return -1; + } + return system(cmd); +} + +static const char *incr_fastapi_cache_path(char *buf, size_t buf_sz) { + const char *cache = cbm_safe_getenv(INCR_TEST_FASTAPI_CACHE_ENV, buf, buf_sz, NULL); + if (cache && cache[0]) { + return cache; + } + int n = snprintf(buf, buf_sz, "%s/%s", cbm_tmpdir(), INCR_TEST_FASTAPI_DEFAULT_CACHE_NAME); + return (n >= 0 && (size_t)n < buf_sz) ? buf : NULL; +} + +static int incr_prepare_managed_fastapi_cache(const char *cache) { + if (!incr_shell_path_ok(cache)) { + return -1; + } + if (incr_fastapi_fixture_valid(cache)) { + return 0; + } + + th_rmtree(cache); + int rc = incr_clone_fastapi_fixture_from_network(cache, false); + if (rc != 0) { + th_rmtree(cache); + return rc; + } + if (!incr_fastapi_fixture_valid(cache)) { + th_rmtree(cache); + return -1; + } + return 0; +} + +static int incr_clone_fastapi_fixture(void) { + char source_buf[CBM_SZ_1K]; + const char *source = cbm_safe_getenv(INCR_TEST_FASTAPI_REPO_ENV, source_buf, + sizeof(source_buf), NULL); + if (source && source[0] && incr_fastapi_fixture_valid(source)) { + printf(" using FastAPI fixture source: %s\n", source); + return incr_clone_fastapi_fixture_from(source); + } + + char cache_buf[CBM_SZ_1K]; + const char *cache = incr_fastapi_cache_path(cache_buf, sizeof(cache_buf)); + int rc = incr_prepare_managed_fastapi_cache(cache); + if (rc == 0) { + printf(" using FastAPI fixture cache: %s\n", cache); + return incr_clone_fastapi_fixture_from(cache); + } + + return incr_clone_fastapi_fixture_from_network(g_repodir, true); +} + /* ── Setup / Teardown ─────────────────────────────────────────────── */ static int incremental_setup(void) { @@ -209,45 +578,13 @@ static int incremental_setup(void) { snprintf(g_repodir, sizeof(g_repodir), "%s/fastapi", g_tmpdir); - /* The fixture is cloned from the network at most once per machine, into a - * persistent cache; every run local-clones from there (seconds, offline). - * The one-time clone is staged and committed with an atomic rename so a - * torn download can never masquerade as a valid cache. */ - const char *cache_home = getenv("CBM_TEST_FIXTURE_CACHE"); - char cache_root[512]; - if (cache_home && cache_home[0]) { - snprintf(cache_root, sizeof(cache_root), "%s", cache_home); - } else { - const char *home = getenv("HOME"); - if (!home || !home[0]) - home = "."; - snprintf(cache_root, sizeof(cache_root), "%s/.cache/cbm-test-fixtures", home); - } - char cache_repo[640]; - snprintf(cache_repo, sizeof(cache_repo), "%s/fastapi-0.99.1", cache_root); - char cmd[1600]; - if (!cbm_is_dir(cache_repo)) { - (void)cbm_mkdir_p(cache_root, 0700); - char cache_stage[700]; - snprintf(cache_stage, sizeof(cache_stage), "%s.stage", cache_repo); - th_rmtree(cache_stage); - snprintf(cmd, sizeof(cmd), - "git clone --depth=1 --branch 0.99.1 --quiet " - "https://github.com/fastapi/fastapi.git '%s' 2>&1", - cache_stage); - int fetch_rc = system(cmd); - if (fetch_rc != 0 || rename(cache_stage, cache_repo) != 0) { - th_rmtree(cache_stage); - if (!cbm_is_dir(cache_repo)) { - printf(" fixture clone failed (rc=%d) — network offline?\n", fetch_rc); - return -1; - } - } - } - snprintf(cmd, sizeof(cmd), "git clone --quiet '%s' '%s' 2>&1", cache_repo, g_repodir); - int rc = system(cmd); + /* incr_clone_fastapi_fixture() implements the same once-per-machine cache + * upstream inlined here, and validates the cached tree (required files plus + * the expected commit) instead of only testing for the directory, so a torn + * download cannot masquerade as a valid cache. */ + int rc = incr_clone_fastapi_fixture(); if (rc != 0) { - printf(" fixture local clone failed (rc=%d)\n", rc); + printf(" FastAPI fixture setup failed (rc=%d) — cache invalid and network offline?\n", rc); return -1; } /* Index the same corpus everywhere: CI historically indexed a sparse @@ -265,6 +602,10 @@ static int incremental_setup(void) { if (!g_project) return -1; + /* Resolve the cache dir via cbm_resolve_cache_dir() so it honors CBM_CACHE_DIR + * and matches the index WRITE path (pipeline.c). Hardcoding ~/.cache here + * made get_node_count read from a different dir than the index wrote under + * CBM_TEST_ISOLATE, yielding 0-node indexes (and a div-by-zero). */ const char *cache_dir = cbm_resolve_cache_dir(); int dbpath_length = cache_dir ? snprintf(g_dbpath, sizeof(g_dbpath), "%s/%s.db", cache_dir, g_project) : -1; @@ -273,11 +614,25 @@ static int incremental_setup(void) { return -1; } - unlink(g_dbpath); + cbm_unlink(g_dbpath); g_srv = cbm_mcp_server_new(NULL); if (!g_srv) return -1; + g_cfg = cbm_config_open(cache_dir); + if (!g_cfg) { + cbm_mcp_server_free(g_srv); + g_srv = NULL; + return -1; + } + cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_REINDEX, "always"); + if (incr_memory_instrumentation_active()) { + char timeout_ms[CBM_SZ_32]; + snprintf(timeout_ms, sizeof(timeout_ms), "%d", + CBM_CONFIG_EXTRACT_TIMEOUT_DEFAULT_MS * INCR_INSTRUMENTED_TIMEOUT_MULTIPLIER); + cbm_config_set(g_cfg, CBM_CONFIG_EXTRACT_TIMEOUT_MS, timeout_ms); + } + cbm_mcp_server_set_config(g_srv, g_cfg); g_rss_before_full = cbm_mem_rss(); @@ -289,6 +644,8 @@ static void incremental_teardown(void) { cbm_mcp_server_free(g_srv); g_srv = NULL; } + cbm_config_close(g_cfg); + g_cfg = NULL; if (g_project) { unlink(g_dbpath); char wal[520], shm[520]; @@ -334,36 +691,18 @@ TEST(incr_full_index) { printf(" [PERF WARNING] full index: %.0fms (>30s)\n", ms); } - /* Memory: bounded budget for a 1100-file Python project. ARM (and other - * large-page) Linux/macOS use 16KB pages vs x86's 4KB; per-allocation page - * rounding inflates RSS ~25-30% for the SAME logical footprint (not a leak — - * ARM ~2385MB on the same index). Scale the budget by page size so the guard - * still catches real runaway memory (a leak would be GBs over) without - * false-failing on large-page architectures. The x86 base budget is 2304MB: - * after the retention/source-text-cap and RAM-tiering work the x86 peak for - * this index settled at ~2050-2072MB (measured across CI runs), so the old - * 2048 limit sat right on the line and flaked; 2304 restores headroom while a - * genuine leak (GBs over) still trips it. */ + /* Memory: use the measured architecture/page-size budget. Diagnostic + * allocators intentionally inflate RSS, so they report instead of failing + * this production-build resource guard. */ size_t rss_delta_mb = peak_mb - (g_rss_before_full / (1024 * 1024)); - int rss_limit_mb = 2304; -#ifndef _WIN32 - if (sysconf(_SC_PAGESIZE) >= 16384) { - rss_limit_mb = 2816; - } -#endif -#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) - /* ARM Linux uses 4KB pages, so the page-size bump above does NOT fire there, - * yet glibc's per-CPU malloc arenas + allocation rounding still inflate RSS - * to the documented ~2385MB for this index (the same inflation Apple silicon - * shows, which the page-size check catches via its 16KB pages). Apply the - * higher ARM budget on any ARM target so the guard still catches a real leak - * (GBs over) without false-failing on 4KB-page ARM Linux (e.g. CI's - * ubuntu-22.04-arm, which measured 2386MB against the un-bumped 2048 limit). */ - if (rss_limit_mb < 2816) { - rss_limit_mb = 2816; + int rss_limit_mb = incr_full_index_rss_limit_mb(); + if (incr_memory_instrumentation_active()) { + printf(" [perf note] full index rss_delta=%zuMB under memory instrumentation " + "(normal limit=%dMB)\n", + rss_delta_mb, rss_limit_mb); + } else { + ASSERT_LT((int)rss_delta_mb, rss_limit_mb); } -#endif - ASSERT_LT((int)rss_delta_mb, rss_limit_mb); printf(" [perf] full: %d nodes, %d edges (%d CALLS, %d IMPORTS) " "in %.0fms, peak=%zuMB\n", @@ -483,34 +822,69 @@ TEST(incr_formatter_run) { int edges_before = get_edge_count(); int calls_before = get_edge_count_by_type("CALLS"); - /* Simulate formatter: touch 50 files */ - reformat_files("fastapi", 50); + /* Simulate a semantics-preserving formatter batch. */ + ASSERT_EQ(cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + int reformat_rc = reformat_files("fastapi", INCR_FORMATTER_MAX_FILES); double ms = 0; size_t peak_mb = 0; resp = index_repo_timed(&ms, &peak_mb); - ASSERT(resp != NULL); - ASSERT(strstr(resp, "indexed") != NULL); + int incremental_response_ok = resp != NULL && strstr(resp, "indexed") != NULL; free(resp); - /* Graph should be nearly identical — formatter adds no functions. - * Warn on >10% variance (can happen with sparse checkout / smaller repos). */ + /* Counts diagnose drift, but canonical incremental/full identity below is + * the pass/fail contract. Appending comments does not change semantics. */ int node_diff = abs(get_node_count() - nodes_before); int edge_diff = abs(get_edge_count() - edges_before); - if (node_diff > nodes_before / 10 || edge_diff > edges_before / 10) { - printf(" [PERF WARNING] formatter drift: node_diff=%d (max %d), edge_diff=%d (max %d)\n", - node_diff, nodes_before / 10, edge_diff, edges_before / 10); - } - - /* CALLS edges: reformatting changes line numbers which affects resolution. */ int calls_diff = abs(get_edge_count_by_type("CALLS") - calls_before); - if (calls_diff > calls_before / 4) { - printf(" [PERF WARNING] CALLS drift: %d (max %d)\n", calls_diff, calls_before / 4); + + char incremental_snapshot_path[CBM_SZ_512]; + int snapshot_path_len = snprintf(incremental_snapshot_path, sizeof(incremental_snapshot_path), + "%s/incr_formatter_incremental.db", g_tmpdir); + int snapshot_path_ok = + snapshot_path_len > 0 && (size_t)snapshot_path_len < sizeof(incremental_snapshot_path); + int snapshot_rc = CBM_STORE_ERR; + int full_response_ok = 0; + int canonical_graph_diff_rc = CBM_NOT_FOUND; + char canonical_graph_diff_error[CBM_SZ_8K] = {0}; + + if (incremental_response_ok && snapshot_path_ok) { + cbm_unlink(incremental_snapshot_path); + snapshot_rc = dump_current_store_to_file(incremental_snapshot_path); + } + if (snapshot_rc == CBM_STORE_OK) { + cbm_unlink(g_dbpath); + resp = index_repo(); + full_response_ok = resp != NULL && strstr(resp, "indexed") != NULL; + free(resp); + } + if (full_response_ok) { + canonical_graph_diff_rc = cbm_test_compare_canonical_graphs( + incremental_snapshot_path, g_dbpath, g_project, canonical_graph_diff_error, + sizeof(canonical_graph_diff_error)); } + if (canonical_graph_diff_rc != 0 && snapshot_rc == CBM_STORE_OK && full_response_ok) { + printf(" [formatter:canonical-diff] %s\n", canonical_graph_diff_error); + preserve_accuracy_artifacts(incremental_snapshot_path, "formatter-canonical-diff"); + } + + cbm_unlink(incremental_snapshot_path); + int restore_config_rc = cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFAULT); - printf(" [perf] reformat 50 files: %.0fms, node_diff=%d edge_diff=%d\n", ms, node_diff, - edge_diff); + printf(" [perf] reformat up to %d files: %.0fms, node_diff=%d edge_diff=%d " + "calls_diff=%d\n", + INCR_FORMATTER_MAX_FILES, ms, node_diff, edge_diff, calls_diff); + ASSERT_EQ(reformat_rc, 0); + ASSERT(incremental_response_ok); + ASSERT(snapshot_path_ok); + ASSERT_EQ(snapshot_rc, CBM_STORE_OK); + ASSERT(full_response_ok); + ASSERT_EQ(restore_config_rc, 0); + ASSERT_EQ(canonical_graph_diff_rc, 0); PASS(); } @@ -819,9 +1193,29 @@ TEST(incr_batch_add_delete) { * ══════════════════════════════════════════════════════════════════ */ TEST(incr_db_deleted_recovery) { - int nodes_before = get_node_count(); - - unlink(g_dbpath); + /* Recovery is an exact graph oracle, so refresh derived results at publish + * instead of comparing the configured deferred view with a clean rebuild + * that necessarily refreshes global semantic edges. */ + ASSERT_EQ(cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + write_file_at("tests/incr_recovery_refresh.py", + "def incr_recovery_refresh():\n return 'recovery'\n"); + char *baseline_response = index_repo(); + ASSERT(baseline_response != NULL); + ASSERT(strstr(baseline_response, "indexed") != NULL); + free(baseline_response); + + char recovery_baseline_path[CBM_SZ_512]; + int recovery_baseline_path_len = + snprintf(recovery_baseline_path, sizeof(recovery_baseline_path), + "%s/incr_db_deleted_recovery_baseline.db", g_tmpdir); + ASSERT_GT(recovery_baseline_path_len, 0); + ASSERT_LT((size_t)recovery_baseline_path_len, sizeof(recovery_baseline_path)); + cbm_unlink(recovery_baseline_path); + ASSERT_EQ(dump_current_store_to_file(recovery_baseline_path), CBM_STORE_OK); + + ASSERT_EQ(cbm_unlink(g_dbpath), 0); double ms = 0; size_t peak_mb = 0; @@ -830,17 +1224,35 @@ TEST(incr_db_deleted_recovery) { ASSERT(strstr(resp, "indexed") != NULL); free(resp); - /* Full reindex must produce similar count */ - int nodes_after = get_node_count(); - int diff_pct = abs(nodes_after - nodes_before) * 100 / nodes_before; - ASSERT_LT(diff_pct, 5); + char canonical_graph_diff_error[CBM_SZ_8K] = {0}; + int canonical_graph_diff_rc = cbm_test_compare_canonical_graphs( + recovery_baseline_path, g_dbpath, g_project, canonical_graph_diff_error, + sizeof(canonical_graph_diff_error)); + if (canonical_graph_diff_rc != 0) { + printf(" [db-recovery:canonical-diff] %s\n", canonical_graph_diff_error); + preserve_accuracy_artifacts(recovery_baseline_path, "db-recovery-canonical-diff"); + } + cbm_unlink(recovery_baseline_path); + delete_file_at("tests/incr_recovery_refresh.py"); + int restore_config_rc = cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFAULT); printf(" [perf] db recovery (full reindex): %.0fms, peak=%zuMB\n", ms, peak_mb); + ASSERT_EQ(restore_config_rc, 0); + ASSERT_EQ(canonical_graph_diff_rc, 0); PASS(); } TEST(incr_accuracy_vs_full) { + /* This test is the strict canonical full-vs-incremental oracle. The + * production default may intentionally defer global semantic-derived edges, + * so opt into derived-results refresh at publish here instead of weakening the graph + * comparison. */ + ASSERT_EQ(cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + /* Modify a file to create a known incremental state */ write_file_at("fastapi/incr_accuracy.py", "def accuracy_a():\n return 1\n" "def accuracy_b():\n return accuracy_a() + 1\n"); @@ -852,6 +1264,17 @@ TEST(incr_accuracy_vs_full) { int incr_nodes = get_node_count(); int incr_edges = get_edge_count(); int incr_calls = get_edge_count_by_type("CALLS"); + int incr_type_counts[ACCURACY_EDGE_TYPE_COUNT] = {0}; + capture_accuracy_edge_counts(incr_type_counts); + handle_breakdown_t incr_handles = capture_handle_breakdown(); + + char incr_snapshot_path[CBM_SZ_512]; + int snap_len = snprintf(incr_snapshot_path, sizeof(incr_snapshot_path), + "%s/incr_accuracy_incremental.db", g_tmpdir); + ASSERT_GT(snap_len, 0); + ASSERT_LT((size_t)snap_len, sizeof(incr_snapshot_path)); + cbm_unlink(incr_snapshot_path); + ASSERT_EQ(dump_current_store_to_file(incr_snapshot_path), CBM_STORE_OK); /* Delete DB, force full reindex */ unlink(g_dbpath); @@ -862,16 +1285,47 @@ TEST(incr_accuracy_vs_full) { int full_nodes = get_node_count(); int full_edges = get_edge_count(); int full_calls = get_edge_count_by_type("CALLS"); + int full_type_counts[ACCURACY_EDGE_TYPE_COUNT] = {0}; + capture_accuracy_edge_counts(full_type_counts); + handle_breakdown_t full_handles = capture_handle_breakdown(); + + /* Counts remain useful diagnostics, but canonical graph equality below is + * the pass/fail contract. */ + if (abs(full_nodes - incr_nodes) > INCR_ACCURACY_NODE_TOLERANCE) { + printf(" [accuracy:nodes] incr=%d full=%d delta=%+d\n", incr_nodes, full_nodes, + incr_nodes - full_nodes); + } + if (abs(full_edges - incr_edges) > INCR_ACCURACY_EDGE_TOLERANCE) { + print_accuracy_edge_diff(incr_type_counts, full_type_counts); + print_handle_breakdown("incr", incr_handles); + print_handle_breakdown("full", full_handles); + } + if (abs(full_calls - incr_calls) > INCR_ACCURACY_CALL_TOLERANCE) { + printf(" [accuracy:calls] incr=%d full=%d delta=%+d\n", incr_calls, full_calls, + incr_calls - full_calls); + } - /* Within tight tolerance (±2 for dedup timing differences) */ - ASSERT_LTE(abs(full_nodes - incr_nodes), 2); - ASSERT_LTE(abs(full_nodes - incr_nodes), 50); - ASSERT_LTE(abs(full_calls - incr_calls), 2); + char diff_err[CBM_SZ_8K] = {0}; + int graph_diff_rc = + cbm_test_compare_canonical_graphs(incr_snapshot_path, g_dbpath, g_project, diff_err, + sizeof(diff_err)); + if (graph_diff_rc != 0) { + print_accuracy_edge_diff(incr_type_counts, full_type_counts); + print_handle_breakdown("incr", incr_handles); + print_handle_breakdown("full", full_handles); + printf(" [accuracy:canonical-diff] %s\n", diff_err); + preserve_accuracy_artifacts(incr_snapshot_path, "canonical-diff"); + } printf(" [accuracy] incr: %d nodes/%d edges, full: %d nodes/%d edges\n", incr_nodes, incr_edges, full_nodes, full_edges); delete_file_at("fastapi/incr_accuracy.py"); + cbm_unlink(incr_snapshot_path); + ASSERT_EQ(cbm_config_set(g_cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFAULT), + 0); + ASSERT_EQ(graph_diff_rc, 0); PASS(); } @@ -2088,6 +2542,21 @@ TEST(tool_index_mode_fast) { PASS(); } +TEST(tool_index_publish_metadata) { + double ms; + char *r = call_tool_timed("index_repository", &ms, "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", + g_repodir); + ASSERT(r != NULL); + ASSERT(strstr(r, "\\\"publish_kind\\\":\\\"") != NULL); + ASSERT(strstr(r, "\\\"graph_changed\\\":") != NULL); + ASSERT(strstr(r, "\\\"publish_kind\\\":\\\"full\\\"") != NULL || + strstr(r, "\\\"publish_kind\\\":\\\"incremental_noop\\\"") != NULL || + strstr(r, "\\\"publish_kind\\\":\\\"incremental_exact\\\"") != NULL || + strstr(r, "\\\"publish_kind\\\":\\\"incremental_containment\\\"") != NULL); + free(r); + PASS(); +} + TEST(tool_index_invalid_path) { double ms; char *r = call_tool_timed("index_repository", &ms, "{\"repo_path\":\"/nonexistent/path/xyz\"}"); @@ -3180,6 +3649,7 @@ SUITE(incremental) { /* Phase 19: index_repository params */ RUN_TEST(tool_index_mode_fast); + RUN_TEST(tool_index_publish_metadata); RUN_TEST(tool_index_invalid_path); RUN_TEST(tool_index_missing_param); diff --git a/tests/test_index_resilience.c b/tests/test_index_resilience.c index 8d8a601a6..c8aefff6b 100644 --- a/tests/test_index_resilience.c +++ b/tests/test_index_resilience.c @@ -97,12 +97,15 @@ static cbm_store_t *ri_index_capture(RProj *lp, char **out_resp) { if (!lp->project) { return NULL; } - const char *home = getenv("HOME"); - if (!home) { - home = "/tmp"; + /* Resolve the cache dir the same way the pipeline does (honors the + * CBM_CACHE_DIR isolation dir test_main.c sets for every run). A + * hardcoded ~/.cache here reads a DIFFERENT store than the one the + * pipeline writes — the "815 empty-store failures" mismatch documented + * at the isolation setup in test_main.c. */ + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir) { + return NULL; } - char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); @@ -391,6 +394,29 @@ TEST(index_parse_partial_reported) { cbm_store_free_coverage(rows, cov_count); ASSERT_TRUE(marked); + /* The metadata describing HOW COMPLETELY this run recorded coverage is + * written by the same publish that wrote those rows (#963). Asserting it + * here is what makes losing it loud. cbm_store_coverage_replace() forwards + * a NULL meta and a NULL meta CLEARS the metadata, so a publish route that + * skips it still returns coverage rows and still reports success, while + * check_index_coverage silently loses the ability to tell "recorded + * complete coverage" from "never recorded coverage" — the exact + * distinction the metadata exists to carry. Asserting the effect, not the + * absence of an error. */ + cbm_coverage_meta_t cov_meta = {0}; + ASSERT_EQ(cbm_store_coverage_meta_get(store, lp.project, &cov_meta), CBM_STORE_OK); + ASSERT_NOT_NULL(cov_meta.recording_status); + ASSERT_STR_EQ("complete", cov_meta.recording_status); + /* Not merely non-NULL: "unknown" is what an index mode the writer does not + * recognize serializes to, so it would pass a null check while telling a + * reader nothing. */ + ASSERT_NOT_NULL(cov_meta.index_mode); + ASSERT_TRUE(strcmp(cov_meta.index_mode, "unknown") != 0); + ASSERT_NOT_NULL(cov_meta.generation); + ASSERT_TRUE(cov_meta.hash_records_complete); + ASSERT_EQ(cov_meta.coverage_version, CBM_COVERAGE_VERSION); + cbm_store_coverage_meta_clear(&cov_meta); + char qargs[900]; snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project); char *qresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs); @@ -635,6 +661,9 @@ TEST(index_not_indexed_by_design_reported) { char *resp2 = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs); ASSERT_NOT_NULL(resp2); free(resp2); + /* qargs was repurposed for query_graph above. Rebuild the index_status + * request instead of accidentally testing strict rejection of graph/query. */ + snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project); char *sresp2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs); ASSERT_NOT_NULL(sresp2); ASSERT_NOT_NULL(strstr(sresp2, "secret.py")); @@ -698,12 +727,11 @@ TEST(index_relative_repo_path_canonicalized) { FAIL("project name derivation failed"); } - const char *home = getenv("HOME"); - if (!home) { - home = "/tmp"; + /* Same CBM_CACHE_DIR-honoring resolution as ri_index_capture above. */ + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir) { + FAIL("cache dir resolution failed"); } - char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); cbm_mkdir(cache_dir); snprintf(lp.dbpath, sizeof(lp.dbpath), "%s/%s.db", cache_dir, lp.project); unlink(lp.dbpath); diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index a0e45d156..bf40b72dc 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -1,12 +1,11 @@ #include "test_framework.h" #include "graph_buffer/graph_buffer.h" #include "pipeline/pipeline_internal.h" +#include "service_patterns.h" #include #include -bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name); - static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) { const cbm_gbuf_edge_t **edges = NULL; int count = 0; @@ -19,6 +18,25 @@ static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) { return 0; } +static int count_handles_to(cbm_gbuf_t *gb, int64_t target_id) { + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_target_type(gb, target_id, "HANDLES", &edges, &count); + return count; +} + +static bool has_handle(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) { + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_target_type(gb, target_id, "HANDLES", &edges, &count); + for (int i = 0; i < count; i++) { + if (edges[i]->source_id == source_id) { + return true; + } + } + return false; +} + TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths) { ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/etc/crio/crio.conf", "requests.get")); ASSERT_FALSE( @@ -28,12 +46,46 @@ TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths) { ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/api", "os.path.join")); ASSERT_FALSE(cbm_service_pattern_is_http_route_literal(NULL, "requests.get")); ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("", "requests.get")); + /* CLI slash-command syntax: ':' mid-segment is not a route param + * (autorun's "/ar:allow", "/ar:a" etc. — not HTTP routes). */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/ar:allow", "app.command")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/ar:a", "app.command")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/gh:pr", "app.command")); + /* Filesystem paths with document/source extensions are never routes. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/new/file.txt", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/fake/path.pdf", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/Users/test/plans/foo.md", "open")); + /* Filesystem roots. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/usr/bin/uv", "subprocess")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/home/user/.claude/plans/bar.md", "open")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/tmp/alpha", "requests.get")); + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/Users/dev/project", "requests.get")); + /* Whitespace: command/description strings are not routes. */ + ASSERT_FALSE(cbm_service_pattern_is_http_route_literal("/autorun test task description", "run")); + /* Positive controls: real routes must still pass. */ ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/api/orders", "requests.get")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/users/:id", "app.route")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/teams/:team/users/:id", "app.route")); + ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("/items/{id}", "router.get")); ASSERT_TRUE(cbm_service_pattern_is_http_route_literal("https://orders.example/api/orders", "requests.get")); PASS(); } +TEST(infrascan_service_pattern_match_uses_qn_boundaries) { + ASSERT_EQ(cbm_service_pattern_match( + "proj.plugins.autorun.tests.test_plugin._dispatch"), + CBM_SVC_NONE); + ASSERT_EQ(cbm_service_pattern_match("proj.myrequests.client.get"), CBM_SVC_NONE); + + ASSERT_EQ(cbm_service_pattern_match("proj.gin.router.GET"), CBM_SVC_ROUTE_REG); + ASSERT_EQ(cbm_service_pattern_match("proj.express.router.get"), CBM_SVC_ROUTE_REG); + ASSERT_EQ(cbm_service_pattern_match("proj.venv.requests.api.get"), CBM_SVC_HTTP); + ASSERT_EQ(cbm_service_pattern_match("proj.service.requests_get"), CBM_SVC_HTTP); + ASSERT_EQ(cbm_service_pattern_match("proj.GuzzleHttp.Client.get"), CBM_SVC_HTTP); + PASS(); +} + TEST(infrascan_route_nodes_skip_bad_http_url_paths) { cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_guard"); ASSERT_NOT_NULL(gb); @@ -45,10 +97,13 @@ TEST(infrascan_route_nodes_skip_bad_http_url_paths) { cbm_gbuf_upsert_node(gb, "Function", "str.split", "str.split", "", 0, 0, "{}"); int64_t empty_callee = cbm_gbuf_upsert_node(gb, "Function", "requests.post", "requests.post", "", 0, 0, "{}"); + int64_t long_callee = + cbm_gbuf_upsert_node(gb, "Function", "requests.put", "requests.put", "", 0, 0, "{}"); ASSERT_GT(caller, 0); ASSERT_GT(fs_callee, 0); ASSERT_GT(split_callee, 0); ASSERT_GT(empty_callee, 0); + ASSERT_GT(long_callee, 0); cbm_gbuf_insert_edge(gb, caller, fs_callee, "HTTP_CALLS", "{\"callee\":\"requests.get\",\"url_path\":\"/etc/crio/crio.conf\"," @@ -58,12 +113,28 @@ TEST(infrascan_route_nodes_skip_bad_http_url_paths) { "\"method\":\"ANY\"}"); cbm_gbuf_insert_edge(gb, caller, empty_callee, "HTTP_CALLS", "{\"callee\":\"requests.get\",\"method\":\"GET\"}"); + char long_path[CBM_SZ_1K]; + const char route_prefix[] = "/api/"; + memset(long_path, 'a', sizeof(long_path)); + memcpy(long_path, route_prefix, sizeof(route_prefix) - 1); + long_path[sizeof(long_path) - 1] = '\0'; + char long_props[CBM_SZ_2K]; + int n = snprintf(long_props, sizeof(long_props), + "{\"callee\":\"requests.put\",\"url_path\":\"%s\",\"method\":\"PUT\"}", + long_path); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(long_props)); + cbm_gbuf_insert_edge(gb, caller, long_callee, "HTTP_CALLS", long_props); cbm_pipeline_create_route_nodes(gb); ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "__route__GET__/etc/crio/crio.conf")); ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "__route__ANY__/locations/")); ASSERT_NULL(cbm_gbuf_find_by_qn(gb, "__route__GET__")); + const cbm_gbuf_node_t **routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 0); cbm_gbuf_free(gb); PASS(); @@ -112,8 +183,143 @@ TEST(infrascan_http_calls_join_matching_handler_route) { PASS(); } +TEST(infrascan_infra_match_does_not_expand_root_handlers_to_external_paths) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_infra_match"); + ASSERT_NOT_NULL(gb); + + int64_t root_route = + cbm_gbuf_upsert_node(gb, "Route", "/", "__route__GET__/", "api/server.py", 0, 0, + "{\"method\":\"GET\"}"); + int64_t root_handler = cbm_gbuf_upsert_node(gb, "Function", "root", "test.root", + "api/server.py", 1, 3, "{}"); + int64_t external = + cbm_gbuf_upsert_node(gb, "Route", "https://github.com/pre-commit/pre-commit-hooks", + "__route__infra__https://github.com/pre-commit/pre-commit-hooks", + ".pre-commit-config.yaml", 0, 0, "{\"source\":\"infra\"}"); + int64_t api_root = cbm_gbuf_upsert_node(gb, "Route", "https://api.example.com/", + "__route__infra__https://api.example.com/", + "deploy.yaml", 0, 0, "{\"source\":\"infra\"}"); + ASSERT_GT(root_route, 0); + ASSERT_GT(root_handler, 0); + ASSERT_GT(external, 0); + ASSERT_GT(api_root, 0); + + cbm_gbuf_insert_edge(gb, root_handler, root_route, "HANDLES", "{\"handler\":\"test.root\"}"); + + cbm_pipeline_create_route_nodes(gb); + + ASSERT_EQ(count_handles_to(gb, external), 0); + ASSERT_EQ(count_handles_to(gb, api_root), 1); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(infrascan_infra_match_uses_all_matching_handler_routes) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_infra_match_all"); + ASSERT_NOT_NULL(gb); + + int64_t route_a = cbm_gbuf_upsert_node(gb, "Route", "/orders", "__route__GET__/orders", + "services/orders/api.py", 0, 0, + "{\"method\":\"GET\"}"); + int64_t route_b = cbm_gbuf_upsert_node(gb, "Route", "/orders", "__route__POST__/orders", + "services/orders/admin.py", 0, 0, + "{\"method\":\"POST\"}"); + int64_t handler_a = cbm_gbuf_upsert_node(gb, "Function", "list_orders", "test.list_orders", + "services/orders/api.py", 1, 3, "{}"); + int64_t handler_b = cbm_gbuf_upsert_node(gb, "Function", "create_order", "test.create_order", + "services/orders/admin.py", 1, 3, "{}"); + int64_t infra = + cbm_gbuf_upsert_node(gb, "Route", "https://orders.example.com/orders", + "__route__infra__https://orders.example.com/orders", "deploy.yaml", + 0, 0, "{\"source\":\"infra\"}"); + ASSERT_GT(route_a, 0); + ASSERT_GT(route_b, 0); + ASSERT_GT(handler_a, 0); + ASSERT_GT(handler_b, 0); + ASSERT_GT(infra, 0); + + cbm_gbuf_insert_edge(gb, handler_a, route_a, "HANDLES", "{\"handler\":\"test.list_orders\"}"); + cbm_gbuf_insert_edge(gb, handler_b, route_b, "HANDLES", "{\"handler\":\"test.create_order\"}"); + + cbm_pipeline_create_route_nodes(gb); + + ASSERT_TRUE(has_handle(gb, handler_a, infra)); + ASSERT_TRUE(has_handle(gb, handler_b, infra)); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(infrascan_prefix_bridge_uses_all_registrars_not_first_edge) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_prefix_bridge"); + ASSERT_NOT_NULL(gb); + + int64_t prefix = + cbm_gbuf_upsert_node(gb, "Route", "/api", "__route__ANY__/api", "svc/router.py", 0, 0, + "{\"method\":\"ANY\"}"); + int64_t users_registrar = cbm_gbuf_upsert_node(gb, "Function", "include_users", + "test.svc.users.router.include_users", + "svc/api/users/router.py", 1, 3, "{}"); + int64_t orders_registrar = cbm_gbuf_upsert_node(gb, "Function", "include_orders", + "test.svc.orders.router.include_orders", + "svc/api/orders/router.py", 1, 3, "{}"); + int64_t users_handler = cbm_gbuf_upsert_node( + gb, "Function", "list_users", "test.svc.users.handlers.list_users", + "svc/api/users/handlers.py", 10, 12, "{\"route_path\":\"/users\"}"); + int64_t orders_handler = cbm_gbuf_upsert_node( + gb, "Function", "list_orders", "test.svc.orders.handlers.list_orders", + "svc/api/orders/handlers.py", 10, 12, "{\"route_path\":\"/orders\"}"); + ASSERT_GT(prefix, 0); + ASSERT_GT(users_registrar, 0); + ASSERT_GT(orders_registrar, 0); + ASSERT_GT(users_handler, 0); + ASSERT_GT(orders_handler, 0); + + cbm_gbuf_insert_edge(gb, users_registrar, prefix, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, orders_registrar, prefix, "CALLS", "{}"); + + cbm_pipeline_create_route_nodes(gb); + + ASSERT_TRUE(has_handle(gb, users_handler, prefix)); + ASSERT_TRUE(has_handle(gb, orders_handler, prefix)); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(infrascan_sveltekit_routes_keep_source_file_ownership) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_sveltekit_route"); + ASSERT_NOT_NULL(gb); + + const char *file_path = "apps/web/src/routes/api/items/+server.ts"; + int64_t file = cbm_gbuf_upsert_node(gb, "File", "+server.ts", "test.apps.web.routes.api.items.server", + file_path, 0, 0, "{}"); + int64_t handler = cbm_gbuf_upsert_node(gb, "Function", "GET", + "test.apps.web.routes.api.items.GET", file_path, 1, 10, + "{}"); + ASSERT_GT(file, 0); + ASSERT_GT(handler, 0); + cbm_gbuf_insert_edge(gb, file, handler, "DEFINES", "{}"); + + cbm_pipeline_create_route_nodes(gb); + + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, "__route__GET__/api/items"); + ASSERT_NOT_NULL(route); + ASSERT_STR_EQ(route->file_path, file_path); + ASSERT_TRUE(has_handle(gb, handler, route->id)); + + cbm_gbuf_free(gb); + PASS(); +} + SUITE(infrascan) { RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths); + RUN_TEST(infrascan_service_pattern_match_uses_qn_boundaries); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); RUN_TEST(infrascan_http_calls_join_matching_handler_route); + RUN_TEST(infrascan_infra_match_does_not_expand_root_handlers_to_external_paths); + RUN_TEST(infrascan_infra_match_uses_all_matching_handler_routes); + RUN_TEST(infrascan_prefix_bridge_uses_all_registrars_not_first_edge); + RUN_TEST(infrascan_sveltekit_routes_keep_source_file_ownership); } diff --git a/tests/test_input_validation.c b/tests/test_input_validation.c new file mode 100644 index 000000000..1a165d95c --- /dev/null +++ b/tests/test_input_validation.c @@ -0,0 +1,1915 @@ +/* + * test_input_validation.c — Tests for parameter validation from fuzz testing. + * Covers: F1 (empty label), F6 (invalid sort_by), F7 (invalid mode), + * F9 (invalid regex), F10 (negative depth), F15 (invalid direction). + * + * Each test creates a minimal MCP server, calls a tool handler with invalid + * input, and asserts the error response contains helpful guidance. + */ +#include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" +#include "../src/foundation/platform.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── Helper: extract inner text content from MCP tool result ── */ +static char *extract_text(const char *mcp_result) { + if (!mcp_result) return NULL; + /* Parse MCP JSON wrapper: {"content":[{"type":"text","text":"..."}]} */ + yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0); + if (!doc) return strdup(mcp_result); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *content = yyjson_obj_get(root, "content"); + if (!content || !yyjson_is_arr(content)) { + yyjson_doc_free(doc); + return strdup(mcp_result); + } + yyjson_val *item = yyjson_arr_get(content, 0); + yyjson_val *text = item ? yyjson_obj_get(item, "text") : NULL; + const char *str = text ? yyjson_get_str(text) : NULL; + char *result = str ? strdup(str) : strdup(mcp_result); + yyjson_doc_free(doc); + return result; +} + +/* ── Helper: create minimal server with pre-populated data ── */ +static cbm_mcp_server_t *setup_validation_server(char *tmp, size_t tmp_sz) { + const char *cache = cbm_resolve_cache_dir(); + int path_len = snprintf(tmp, tmp_sz, "%s/cbm-test-validation-XXXXXX", cache); + if (path_len < 0 || (size_t)path_len >= tmp_sz) + return NULL; + if (!cbm_mkdtemp(tmp)) return NULL; + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) return NULL; + + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { cbm_mcp_server_free(srv); return NULL; } + + const char *proj = "validation-test"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, tmp); + + /* Insert test nodes: 2 functions + 1 call edge */ + cbm_node_t foo = {.project = proj, .label = "Function", .name = "foo", + .qualified_name = "validation-test.test.foo", + .file_path = "test.c", .start_line = 1, .end_line = 1}; + cbm_node_t bar = {.project = proj, .label = "Function", .name = "bar", + .qualified_name = "validation-test.test.bar", + .file_path = "test.c", .start_line = 2, .end_line = 2}; + cbm_node_t alpha = {.project = proj, + .label = "Function", + .name = "alphaWorker", + .qualified_name = "validation-test.services.alphaWorker", + .file_path = "worker.c", + .start_line = 3, + .end_line = 3}; + cbm_node_t beta = {.project = proj, + .label = "Function", + .name = "betaHandler", + .qualified_name = "validation-test.services.betaHandler", + .file_path = "handler.c", + .start_line = 4, + .end_line = 4}; + cbm_store_upsert_node(st, &foo); + cbm_store_upsert_node(st, &bar); + cbm_store_upsert_node(st, &alpha); + cbm_store_upsert_node(st, &beta); + cbm_edge_t e = {.project = proj, .source_id = 2, .target_id = 1, .type = "CALLS"}; + cbm_store_insert_edge(st, &e); + + return srv; +} + +static void cleanup_validation_dir(const char *dir) { + th_cleanup(dir); +} + +/* ══════════════════════════════════════════════════════════════════ + * F1: Empty label treated as no filter (not silently returning 0) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f1_empty_label_returns_results) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"label\":\"\",\"limit\":5}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Empty label should be treated as "no label filter" → returns all nodes */ + /* Should NOT return error, and total should be > 0 if project has data */ + ASSERT_NULL(strstr(resp, "\"error\"")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * F6: Invalid sort_by returns error with valid values + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f6_invalid_sort_by_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"sort_by\":\"invalid_value\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Must return error mentioning sort_by */ + ASSERT_NOT_NULL(strstr(resp, "error")); + ASSERT_NOT_NULL(strstr(resp, "sort_by")); + /* Must list valid values */ + ASSERT_NOT_NULL(strstr(resp, "relevance")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* Edge case: sort_by with typo "degre" (missing 'e') */ +TEST(f6_sort_by_typo_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"sort_by\":\"degre\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NOT_NULL(strstr(resp, "error")); + ASSERT_NOT_NULL(strstr(resp, "degree")); /* suggest correct value */ + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * F9: Invalid regex in name_pattern returns error + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f9_invalid_regex_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"(\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Must return error mentioning regex/pattern */ + ASSERT_NOT_NULL(strstr(resp, "error")); + ASSERT_TRUE(strstr(resp, "regex") || strstr(resp, "pattern")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* Edge case: valid regex should NOT error */ +TEST(f9_valid_regex_succeeds) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"foo.*bar\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Valid regex should NOT produce error */ + ASSERT_NULL(strstr(resp, "\"error\":\"invalid regex")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * F6: sort_by 'calls' and 'linkrank' must be accepted (Bug 1) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f6_sort_by_calls_accepted) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"sort_by\":\"calls\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "invalid sort_by")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f6_sort_by_linkrank_accepted) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"sort_by\":\"linkrank\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "invalid sort_by")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * F9: Glob wildcard patterns auto-converted to regex (Bug 2) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f9_glob_star_autoconverted) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = + cbm_mcp_handle_tool(srv, "search_graph", "{\"name_pattern\":\"*Worker*\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "alphaWorker")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f9_glob_question_autoconverted) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"*alpha?orker*\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "alphaWorker")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f9_valid_regex_shaped_glob_is_normalized_before_compile) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"alpha?orker|beta?andler\",\"limit\":5," + "\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "alphaWorker")); + ASSERT_NOT_NULL(strstr(resp, "betaHandler")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f9_valid_regex_still_works) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\".*tool.*\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "error")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f9_truly_invalid_pattern_still_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"(\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "error")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f9_qn_pattern_glob_autoconverted) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"qn_pattern\":\"*Handler*\",\"limit\":3}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "betaHandler")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * F10: Negative depth clamped to 1 + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f10_negative_depth_returns_results) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"foo\",\"depth\":-1}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Should NOT return empty — depth clamped to 1, function "foo" exists */ + /* At minimum should have function name in response */ + ASSERT_NOT_NULL(strstr(resp, "foo")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Bug 3: trace_path fuzzy fallback on case mismatch + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_case_mismatch_finds_via_fallback) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* "Foo" does not exist — only "foo" does. Fallback search should find it. + * No project passed: resolve_store returns in-memory store, fallback search + * has no project filter, finds "foo", re-queries with result's project. */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"Foo\",\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* Must NOT contain "function not found" — fallback should resolve */ + ASSERT_NULL(strstr(resp, "function not found")); + /* Response should contain "function" key (BFS result) and direction */ + ASSERT_NOT_NULL(strstr(resp, "\"function\"")); + ASSERT_NOT_NULL(strstr(resp, "\"direction\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(trace_exact_match_still_works) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Exact name "foo" should work directly without fallback. + * No project: resolve_store returns in-memory store, find_nodes_by_name + * uses project=NULL which binds NULL (won't match). Falls to fallback + * which finds "foo" via search (no project filter). */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"foo\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "function not found")); + ASSERT_NOT_NULL(strstr(resp, "foo")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(trace_truly_missing_still_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* "nonexistent_xyz" doesn't match anything — should still error */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"nonexistent_xyz\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "function not found")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * F15: Invalid direction returns error with valid values + * ══════════════════════════════════════════════════════════════════ */ + +TEST(f15_invalid_direction_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"foo\",\"direction\":\"invalid\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Must return error mentioning direction */ + ASSERT_NOT_NULL(strstr(resp, "error")); + ASSERT_NOT_NULL(strstr(resp, "direction")); + /* Must list valid values */ + ASSERT_NOT_NULL(strstr(resp, "inbound")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* Edge case: valid direction "outbound" should NOT error */ +TEST(f15_valid_direction_succeeds) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"foo\",\"direction\":\"outbound\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Valid direction should NOT produce error about direction */ + ASSERT_NULL(strstr(resp, "invalid direction")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(trace_invalid_mode_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"foo\",\"mode\":\"typo\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NOT_NULL(strstr(resp, "error")); + ASSERT_NOT_NULL(strstr(resp, "mode")); + ASSERT_NOT_NULL(strstr(resp, "calls")); + ASSERT_NOT_NULL(strstr(resp, "data_flow")); + ASSERT_NOT_NULL(strstr(resp, "cross_service")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * G1: Summary mode includes results_suppressed indicator + * ══════════════════════════════════════════════════════════════════ */ + +TEST(g1_summary_mode_has_results_key) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Pass project explicitly to ensure store is found. + * format:"json" opts into the legacy JSON summary shape (G1 contract). */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"mode\":\"summary\",\"limit\":100,\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* G1: summary mode must include "results" key and results_suppressed */ + ASSERT_NOT_NULL(strstr(resp, "\"total\"")); + ASSERT_NOT_NULL(strstr(resp, "\"results\"")); + ASSERT_NOT_NULL(strstr(resp, "results_suppressed")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * CQ-3: Cypher + search-only filter is rejected actionably + * ══════════════════════════════════════════════════════════════════ */ + +TEST(cq3_cypher_with_label_rejected) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* query_graph has never advertised label. Strict validation prevents an + * apparently successful call from silently ignoring the search filter. */ + char *raw = cbm_mcp_handle_tool(srv, "query_graph", + "{\"cypher\":\"MATCH (n:Function) RETURN n.name LIMIT 5\"," + "\"label\":\"Class\",\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NOT_NULL(strstr(resp, "unknown argument 'label'")); + ASSERT_NOT_NULL(strstr(resp, "supported arguments")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * IX-2: Status shows "indexing" during active index + * ══════════════════════════════════════════════════════════════════ */ + +TEST(ix2_status_resource_format) { + /* IX-2: Verify status resource has expected fields when server has no data. + * Can't set autoindex_failed on opaque struct, but we can verify the + * not_indexed status path returns action_required field. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* Server with no indexed data should report not_indexed with action hint */ + char *raw = cbm_mcp_handle_tool(srv, "index_status", "{}"); + /* index_status without a project returns an error — that's expected */ + ASSERT_NOT_NULL(raw); + free(raw); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Pattern OR-search: unified name+qn search (Change 1c) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(pattern_or_search_graph) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* pattern="foo" should match node named "foo" (OR across name and qualified_name) */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"pattern\":\"foo\",\"limit\":5,\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"results\"")); /* results array present */ + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(pattern_or_search_graph_normalizes_valid_regex_shaped_glob) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"pattern\":\"services.alpha?orker|beta?andler\",\"limit\":5," + "\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "alphaWorker")); + ASSERT_NOT_NULL(strstr(resp, "betaHandler")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(f9_explicit_group_and_class_regex_quantifiers_stay_regex) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"(alpha)?Worker|[ab].*Handler\",\"limit\":5," + "\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "alphaWorker")); + ASSERT_NOT_NULL(strstr(resp, "betaHandler")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Source search via search_in="source" dispatch + * ══════════════════════════════════════════════════════════════════ */ + +TEST(source_search_via_search_in_param) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Write a file into the tmpdir so grep has something to search */ + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/hello.c", tmp); + FILE *f = fopen(src_path, "w"); + if (f) { fputs("/* cbm_unique_grep_token */\n", f); fclose(f); } + + /* search_in="source" with explicit project slug dispatches to handle_search_code + * and finds the file we wrote above. */ + char args[512]; + snprintf(args, sizeof(args), + "{\"pattern\":\"cbm_unique_grep_token\"," + "\"search_in\":\"source\"," + "\"project\":\"validation-test\",\"format\":\"json\"}"); + char *raw = cbm_mcp_handle_tool(srv, "search_code", args); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + /* Should return matches array, NOT "project not found" */ + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"matches\"")); + ASSERT_NOT_NULL(strstr(resp, "cbm_unique_grep_token")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Source search: path-based project arg normalizes to slug + * (Bug: get_project_root didn't convert /path → slug, causing + * "project not found" even when the project was indexed) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(source_search_path_project_normalizes_to_slug) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Write a file with a known token */ + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/hello.c", tmp); + FILE *f = fopen(src_path, "w"); + if (f) { fputs("/* path_slug_normalize_token */\n", f); fclose(f); } + + /* The project name "validation-test" was stored with root_path=tmp. + * Passing project=tmp (the root_path) should normalize via get_project_root. + * NOTE: this works when cbm_project_name_from_path(tmp) matches the stored + * project name. For arbitrary test slugs it won't — this test verifies the + * slug-based path works (project="validation-test" matches current_project). */ + char args[512]; + snprintf(args, sizeof(args), + "{\"pattern\":\"path_slug_normalize_token\"," + "\"search_in\":\"source\"," + "\"project\":\"validation-test\",\"format\":\"json\"}"); + char *raw = cbm_mcp_handle_tool(srv, "search_code", args); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"matches\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Verify search_in="graph" (default) still does graph search + * ══════════════════════════════════════════════════════════════════ */ + +TEST(source_search_default_is_graph) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* No search_in → defaults to graph search → returns results array */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"pattern\":\"foo\",\"limit\":5,\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"results\"")); /* graph search returns results array */ + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * summary=true bool alias (Change 2c) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(summary_bool_alias) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"summary\":true,\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + /* Summary mode: by_label and by_file_top20 present */ + ASSERT_NOT_NULL(strstr(resp, "\"by_label\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * case_sensitive graph search (Change 2b) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(case_sensitive_graph_search) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* case_sensitive=true: "FOO" should NOT match node named "foo" */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"FOO\",\"case_sensitive\":true," + "\"limit\":5,\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + /* Should return 0 results (no uppercase FOO in test store) */ + ASSERT_NOT_NULL(strstr(resp, "\"total\":0")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Config: compact default false + * ══════════════════════════════════════════════════════════════════ */ + +TEST(config_compact_default_false) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Set compact=false in config, then call without compact param */ + cbm_config_t *cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(cfg); + cbm_config_set(cfg, "compact", "false"); + cbm_mcp_server_set_config(srv, cfg); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + free(resp); + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(config_response_format_json_with_toon_override) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_config_t *cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_DEFAULT_RESPONSE_FORMAT, + CBM_MCP_OUTPUT_FORMAT_JSON), + 0); + ASSERT_TRUE(cbm_config_set(cfg, CBM_CONFIG_DEFAULT_RESPONSE_FORMAT, "yaml") != 0); + cbm_mcp_server_set_config(srv, cfg); + + const char *query = "MATCH (n:Function) RETURN n.name LIMIT 2"; + char *raw = cbm_mcp_handle_tool( + srv, "query_graph", + "{\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 2\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_EQ(resp[0], '{'); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"rows\"")); + free(resp); + + raw = cbm_mcp_handle_tool(srv, "get_graph_schema", + "{\"project\":\"validation-test\"}"); + resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_EQ(resp[0], '{'); + ASSERT_NULL(strstr(resp, "\"error\"")); + free(resp); + + char args[256]; + snprintf(args, sizeof(args), "{\"query\":\"%s\",\"format\":\"toon\"}", query); + raw = cbm_mcp_handle_tool(srv, "query_graph", args); + resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "rows[")); + ASSERT_TRUE(resp[0] != '{'); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(toon_first_response_context_is_native_toon) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "validation-test"); + + char *raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"name_pattern\":\"alpha\",\"limit\":2,\"format\":\"toon\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* A TOON response must never contain a verbatim JSON subdocument. The + * first-response context keeps the same facts under explicit TOON keys. */ + ASSERT_NULL(strstr(resp, "context: {")); + ASSERT_NULL(strstr(resp, "{\"_context\":")); + ASSERT_NOT_NULL(strstr(resp, "session_project: validation-test")); + ASSERT_NOT_NULL(strstr(resp, "_context_status: ready")); + ASSERT_NOT_NULL(strstr(resp, "_context_project: validation-test")); + ASSERT_NOT_NULL(strstr(resp, "_context_node_labels[")); + ASSERT_NOT_NULL(strstr(resp, "_context_edge_types[")); + + free(resp); + + /* Context is delivered once, while the lightweight session identity is + * retained on later TOON responses just as it is for JSON responses. */ + raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"name_pattern\":\"beta\",\"limit\":2,\"format\":\"toon\"}"); + resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "session_project: validation-test")); + ASSERT_NULL(strstr(resp, "_context_status:")); + ASSERT_NULL(strstr(resp, "_context_node_labels[")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(toon_plain_text_error_separates_first_response_context) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "validation-test"); + + char *raw = cbm_mcp_handle_tool( + srv, "search_code", + "{\"pattern\":\"alpha\",\"file_pattern\":\";\",\"format\":\"toon\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NOT_NULL(strstr(resp, "path or file_pattern contains invalid characters")); + ASSERT_NOT_NULL(strstr(resp, "\nsession_project: validation-test")); + ASSERT_NULL(strstr(resp, "characterssession_project:")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(toon_context_injection_config_is_respected) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "validation-test"); + cbm_config_t *cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, "context_injection", "false"), 0); + cbm_mcp_server_set_config(srv, cfg); + + char *raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"name_pattern\":\"alpha\",\"limit\":2,\"format\":\"toon\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "session_project: validation-test")); + ASSERT_NULL(strstr(resp, "_context_status:")); + ASSERT_NULL(strstr(resp, "_context_node_labels[")); + ASSERT_NULL(strstr(resp, "context: {")); + + free(resp); + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(graph_schema_formats_preserve_bounded_facts) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + static const char *const labels[] = {"Class", "Interface", "Method", "Field", + "Module", "Variable", "Enum", "Type"}; + for (size_t i = 0; i < sizeof(labels) / sizeof(labels[0]); i++) { + char name[64]; + char qualified_name[128]; + snprintf(name, sizeof(name), "SchemaNode%zu", i); + snprintf(qualified_name, sizeof(qualified_name), "validation-test.schema.%s", name); + cbm_node_t node = {.project = "validation-test", + .label = labels[i], + .name = name, + .qualified_name = qualified_name, + .file_path = "schema-fixture.c", + .start_line = (int)i + 1, + .end_line = (int)i + 1, + .properties_json = "{\"schema_fixture_property\":true}"}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + } + + char *raw = cbm_mcp_handle_tool( + srv, "get_graph_schema", + "{\"project\":\"validation-test\",\"format\":\"json\"}"); + char *json = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(json); + ASSERT_EQ(json[0], '{'); + ASSERT_NOT_NULL(strstr(json, "\"node_labels\"")); + ASSERT_NOT_NULL(strstr(json, "\"properties\"")); + ASSERT_NOT_NULL(strstr(json, "\"property_key_limit_per_label_or_type\":50")); + ASSERT_NOT_NULL(strstr(json, "\"relationship_pattern_limit\":50")); + ASSERT_NOT_NULL(strstr(json, "\"Function\"")); + ASSERT_NOT_NULL(strstr(json, "\"CALLS\"")); + + raw = cbm_mcp_handle_tool(srv, "get_graph_schema", + "{\"project\":\"validation-test\"}"); + char *default_toon = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(default_toon); + ASSERT_TRUE(default_toon[0] != '{'); + ASSERT_NOT_NULL(strstr(default_toon, "node_labels[")); + free(default_toon); + + raw = cbm_mcp_handle_tool( + srv, "get_graph_schema", + "{\"project\":\"validation-test\",\"format\":\"toon\"}"); + char *toon = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(toon); + ASSERT_TRUE(toon[0] != '{'); + ASSERT_NOT_NULL(strstr(toon, "node_base_properties:")); + ASSERT_NOT_NULL(strstr(toon, "property_key_limit_per_label_or_type: 50")); + ASSERT_NOT_NULL(strstr(toon, "relationship_pattern_limit: 50")); + ASSERT_NOT_NULL(strstr(toon, "node_labels[")); + ASSERT_NOT_NULL(strstr(toon, "edge_base_properties:")); + ASSERT_NOT_NULL(strstr(toon, "edge_types[")); + ASSERT_NOT_NULL(strstr(toon, "relationship_patterns[")); + ASSERT_NOT_NULL(strstr(toon, "MATCH (source:Function)-[:CALLS]->(target:Function)")); + ASSERT_NOT_NULL(strstr(toon, "Function")); + ASSERT_NOT_NULL(strstr(toon, "CALLS")); + ASSERT_TRUE(strlen(toon) < strlen(json)); + + free(toon); + free(json); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Config: default_sort_by=calls + * ══════════════════════════════════════════════════════════════════ */ + +TEST(config_default_sort_by_calls) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_config_t *cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(cfg); + cbm_config_set(cfg, "default_sort_by", "calls"); + cbm_mcp_server_set_config(srv, cfg); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "invalid sort_by")); /* valid sort, no error */ + free(resp); + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * trace_path accepts qualified_name param (Change 3a) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_accepts_qualified_name_param) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Passing full qualified_name should not error (even if BFS finds 0 callers on test store). + * Must NOT return "function not found" — QN lookup path fires first. */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"qualified_name\":\"validation-test.test.foo\",\"project\":\"validation-test\",\"direction\":\"outbound\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + /* Should find "foo" node via QN and return trace output, not "function not found" */ + ASSERT_NULL(strstr(resp, "\"error\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * pattern= glob wildcard auto-converts to regex (*foo* → .*foo.*) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(pattern_glob_wildcards_auto_convert) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* "*foo*" is not valid regex but valid glob — should auto-convert and find "foo" node */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"pattern\":\"*foo*\",\"limit\":5,\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"results\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * pattern= invalid regex that can't be salvaged returns error + * ══════════════════════════════════════════════════════════════════ */ + +TEST(pattern_invalid_regex_returns_error) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* "[invalid" is not valid regex and not a glob — should return error */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"pattern\":\"[invalid\",\"limit\":5}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "invalid regex")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * trace: when both function_name AND qualified_name given, QN takes + * priority (QN-first lookup runs before name-based lookup) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_qn_takes_priority_over_function_name) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Pass a valid QN and a non-existent function_name. + * QN lookup should find "foo" and succeed; function_name is ignored. */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"qualified_name\":\"validation-test.test.foo\"," + "\"function_name\":\"does_not_exist_anywhere\"," + "\"project\":\"validation-test\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + /* QN lookup finds "foo" → no error, trace succeeds */ + ASSERT_NULL(strstr(resp, "\"error\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * trace: qualified_name not found returns actionable error hint + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_qn_not_found_returns_specific_hint) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Pass a QN that doesn't exist — should get specific hint about using pattern= */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"qualified_name\":\"no-such.project.func\"," + "\"project\":\"validation-test\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + /* Should mention qualified_name in error, not generic function_name hint */ + ASSERT_NOT_NULL(strstr(resp, "qualified_name")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * detect_changes: slug project doesn't return "project not found" + * (Tests that get_project_root handles slug args correctly after + * the path-normalization refactor.) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(detect_changes_slug_project_finds_root) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"validation-test\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + /* Should find the project root (not "project not found" error) */ + ASSERT_NULL(strstr(resp, "\"error\":\"project not found\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * manage_adr: slug project doesn't return "project not found" + * ══════════════════════════════════════════════════════════════════ */ + +TEST(manage_adr_slug_project_finds_root) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"validation-test\",\"mode\":\"get\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + /* Should find the project root — either returns ADR content or "not found" for the file, + * but NOT "project not found" (the store lookup error). */ + ASSERT_NULL(strstr(resp, "\"error\":\"project not found\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Tilde expansion: project="~/relpath" expands correctly + * (get_project_root uses expand_tilde before realpath) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(source_search_tilde_project_expands) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Build a project path relative to $HOME (e.g. ~/... pointing to tmp) */ + const char *home = getenv("HOME"); + if (!home || strncmp(tmp, home, strlen(home)) != 0) { + /* tmp is not under $HOME — skip this test on this machine */ + cbm_mcp_server_free(srv); cleanup_validation_dir(tmp); + PASS(); + } + /* Compute tilde path: replace $HOME prefix with ~ */ + char tilde_path[320]; + snprintf(tilde_path, sizeof(tilde_path), "~%s", tmp + strlen(home)); + + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/tilde.c", tmp); + FILE *f = fopen(src_path, "w"); + if (f) { fputs("/* tilde_expand_token */\n", f); fclose(f); } + + /* Pass project as tilde path — should expand to absolute and find root */ + char args[512]; + snprintf(args, sizeof(args), + "{\"pattern\":\"tilde_expand_token\"," + "\"search_in\":\"source\"," + "\"project\":\"%s\"}", tilde_path); + char *raw = cbm_mcp_handle_tool(srv, "search_code", args); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"matches\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(graph_search_tilde_project_autoindexes) { + char fake_home[256]; + snprintf(fake_home, sizeof(fake_home), "/tmp/cbm_tilde_home_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(fake_home)); + + char repo_dir[320]; + snprintf(repo_dir, sizeof(repo_dir), "%s/repo", fake_home); + ASSERT_TRUE(cbm_mkdir_p(repo_dir, 0755)); + + char src_path[360]; + snprintf(src_path, sizeof(src_path), "%s/main.c", repo_dir); + FILE *f = fopen(src_path, "w"); + ASSERT_NOT_NULL(f); + fputs("void tilde_graph_autoindex_sentinel(void) {}\n", f); + fclose(f); + + const char *old_home = getenv("HOME"); + const char *old_auto_index = getenv("CBM_AUTO_INDEX"); + char *old_home_copy = old_home ? strdup(old_home) : NULL; + char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL; + if (old_home) ASSERT_NOT_NULL(old_home_copy); + if (old_auto_index) ASSERT_NOT_NULL(old_auto_index_copy); + cbm_setenv("HOME", fake_home, 1); + cbm_setenv("CBM_AUTO_INDEX", "true", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"~/repo\",\"pattern\":\"tilde_graph_autoindex_sentinel\"}"); + char *resp = extract_text(raw); + free(raw); + bool has_match = resp && strstr(resp, "tilde_graph_autoindex_sentinel") != NULL; + free(resp); + cbm_mcp_server_free(srv); + + if (old_home_copy) { + cbm_setenv("HOME", old_home_copy, 1); + free(old_home_copy); + } else { + cbm_unsetenv("HOME"); + } + if (old_auto_index_copy) { + cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1); + free(old_auto_index_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + cbm_unlink(src_path); + cbm_rmdir(repo_dir); + cbm_rmdir(fake_home); + + ASSERT_TRUE(has_match); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * project_is_path: path-format project arg routes through slug + * conversion in get_project_root (regression for path-based project) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(source_search_no_project_falls_back_to_session) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Simulate session_project being set (as detect_session would do after initialize) */ + cbm_mcp_server_set_session_project(srv, "validation-test"); + + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/session.c", tmp); + FILE *f = fopen(src_path, "w"); + if (f) { fputs("/* session_fallback_token */\n", f); fclose(f); } + + /* No project= arg — get_project_root falls back to session_project */ + char *raw = cbm_mcp_handle_tool(srv, "search_code", + "{\"pattern\":\"session_fallback_token\",\"search_in\":\"source\"," + "\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"matches\"")); + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Path-based auto-index: project= is a full directory path that is + * NOT under session_root (Bug #4 — .gitignore-excluded separate repo) + * + * When project= is an absolute path to an accessible directory that + * hasn't been indexed yet and differs from session_root, codebase-memory + * must auto-index that path directly (not session_root). + * ══════════════════════════════════════════════════════════════════ */ + +TEST(path_project_auto_indexes_separate_directory) { + /* Create two separate temp dirs: + * session_tmp = first project queried (establishes session_root via public API) + * target_tmp = second project queried (separate path, simulates .gitignore subdir) + * + * Workflow mirrors Bug #4: user queries upstream repo that lives in .gitignore + * of the main project, after the main project session is already active. */ + char session_tmp[256]; + snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_ai_sess_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp)); + + char session_src[320]; + snprintf(session_src, sizeof(session_src), "%s/main.c", session_tmp); + FILE *fp = fopen(session_src, "w"); + if (fp) { fputs("void session_fn(void) {}\n", fp); fclose(fp); } + + char target_tmp[256]; + snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_ai_tgt_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp)); + + char target_src[320]; + snprintf(target_src, sizeof(target_src), "%s/upstream.c", target_tmp); + fp = fopen(target_src, "w"); + if (fp) { fputs("void path_autoindex_sentinel(void) {}\n", fp); fclose(fp); } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + const char *old_auto_index = getenv("CBM_AUTO_INDEX"); + char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL; + if (old_auto_index) { + ASSERT_NOT_NULL(old_auto_index_copy); + } + cbm_setenv("CBM_AUTO_INDEX", "true", 1); + + /* First query: session project path → establishes session_root internally */ + char args1[512]; + snprintf(args1, sizeof(args1), + "{\"project\":\"%s\",\"pattern\":\"session_fn\",\"search_in\":\"source\"}", + session_tmp); + char *raw1 = cbm_mcp_handle_tool(srv, "search_code", args1); + free(raw1); /* result not checked — just establishing session_root */ + + /* Second query: DIFFERENT path — resolve_project_store must auto-index it. + * Use graph search (not source grep) so resolve_project_store runs the + * path-based auto-index and the indexed nodes are searchable. */ + char args2[512]; + snprintf(args2, sizeof(args2), + "{\"project\":\"%s\",\"pattern\":\"path_autoindex_sentinel\"}", target_tmp); + char *raw2 = cbm_mcp_handle_tool(srv, "search_graph", args2); + char *resp = extract_text(raw2); free(raw2); + bool has_match = resp && strstr(resp, "path_autoindex_sentinel") != NULL; + free(resp); + + cbm_mcp_server_free(srv); + if (old_auto_index_copy) { + cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1); + free(old_auto_index_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + cbm_unlink(target_src); + cbm_rmdir(target_tmp); + cbm_unlink(session_src); + cbm_rmdir(session_tmp); + + ASSERT_TRUE(has_match); + PASS(); +} + +TEST(path_project_autoindex_respects_file_limit) { + char session_tmp[256]; + snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_limit_sess_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp)); + + char session_src[320]; + snprintf(session_src, sizeof(session_src), "%s/main.c", session_tmp); + FILE *fp = fopen(session_src, "w"); + if (fp) { fputs("void session_limit_fn(void) {}\n", fp); fclose(fp); } + + char target_tmp[256]; + snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_limit_tgt_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp)); + + char target_src1[320]; + char target_src2[320]; + snprintf(target_src1, sizeof(target_src1), "%s/upstream.c", target_tmp); + snprintf(target_src2, sizeof(target_src2), "%s/extra.c", target_tmp); + fp = fopen(target_src1, "w"); + if (fp) { fputs("void path_limit_sentinel(void) {}\n", fp); fclose(fp); } + fp = fopen(target_src2, "w"); + if (fp) { fputs("void path_limit_extra(void) {}\n", fp); fclose(fp); } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + const char *old_auto_index = getenv("CBM_AUTO_INDEX"); + const char *old_limit = getenv("CBM_AUTO_INDEX_LIMIT"); + char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL; + char *old_limit_copy = old_limit ? strdup(old_limit) : NULL; + if (old_auto_index) { + ASSERT_NOT_NULL(old_auto_index_copy); + } + if (old_limit) { + ASSERT_NOT_NULL(old_limit_copy); + } + cbm_setenv("CBM_AUTO_INDEX", "true", 1); + cbm_setenv("CBM_AUTO_INDEX_LIMIT", "1", 1); + + char args1[512]; + snprintf(args1, sizeof(args1), + "{\"project\":\"%s\",\"pattern\":\"session_limit_fn\",\"search_in\":\"source\"}", + session_tmp); + char *raw1 = cbm_mcp_handle_tool(srv, "search_code", args1); + free(raw1); + + char args2[512]; + snprintf(args2, sizeof(args2), + "{\"project\":\"%s\",\"pattern\":\"path_limit_sentinel\"}", target_tmp); + char *raw2 = cbm_mcp_handle_tool(srv, "search_graph", args2); + char *resp = extract_text(raw2); + free(raw2); + bool has_match = resp && strstr(resp, "path_limit_sentinel") != NULL; + free(resp); + + cbm_mcp_server_free(srv); + if (old_auto_index_copy) { + cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1); + free(old_auto_index_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + if (old_limit_copy) { + cbm_setenv("CBM_AUTO_INDEX_LIMIT", old_limit_copy, 1); + free(old_limit_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX_LIMIT"); + } + cbm_unlink(target_src1); + cbm_unlink(target_src2); + cbm_rmdir(target_tmp); + cbm_unlink(session_src); + cbm_rmdir(session_tmp); + + ASSERT_FALSE(has_match); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Path-based auto-index must run dependency auto-indexing exactly like + * the session-root branch: explicit auto_index_deps=true triggers + * cbm_mcp_auto_index_deps after the project index; config can disable + * it or cap it via auto_dep_limit. Analysis: + * notes/2026-07-21-2332-path-autoindex-dependency-asymmetry-analysis.md + * ══════════════════════════════════════════════════════════════════ */ + +/* Build a target repo (Makefile ecosystem) with one vendored dependency. + * vendor/ is excluded from project discovery (discover.c skip list), so the + * dep sentinel is only searchable when dependency indexing actually ran. */ +static int setup_path_dep_target(const char *target_tmp) { + if (th_write_file(TH_PATH(target_tmp, "Makefile"), "all:\n\tcc upstream.c\n") != 0) { + return -1; + } + if (th_write_file(TH_PATH(target_tmp, "upstream.c"), + "void path_dep_upstream_fn(void) {}\n") != 0) { + return -1; + } + char vendor[512]; + snprintf(vendor, sizeof(vendor), "%s/vendor/libdep", target_tmp); + if (th_mkdir_p(vendor) != 0) { + return -1; + } + return th_write_file(TH_PATH(vendor, "lib.c"), + "int path_dep_sentinel(void) { return 1; }\n"); +} + +/* Shared driver: establish a session on session_tmp (Bug 4 workflow), then + * query target_tmp by path to fire the path-based auto-index, then search the + * target again for project and dependency sentinels. Writes results through + * out params; caller asserts. */ +static void run_path_dep_queries(cbm_mcp_server_t *srv, const char *session_tmp, + const char *target_tmp, const char *dep_pattern, + bool *out_project_indexed, char **out_dep_resp) { + char args1[512]; + snprintf(args1, sizeof(args1), + "{\"project\":\"%s\",\"pattern\":\"session_dep_fn\",\"search_in\":\"source\"}", + session_tmp); + char *raw1 = cbm_mcp_handle_tool(srv, "search_code", args1); + free(raw1); /* result not checked — establishes session_root */ + + /* Trigger path-based auto-index of the separate target repo. */ + char args2[512]; + snprintf(args2, sizeof(args2), + "{\"project\":\"%s\",\"pattern\":\"path_dep_upstream_fn\"}", target_tmp); + char *raw2 = cbm_mcp_handle_tool(srv, "search_graph", args2); + char *resp = extract_text(raw2); + free(raw2); + *out_project_indexed = resp && strstr(resp, "path_dep_upstream_fn") != NULL; + free(resp); + + /* Fresh call: the store exists now, so this is a plain prefix search + * that includes {slug}.dep.* sub-projects. */ + char args3[512]; + snprintf(args3, sizeof(args3), "{\"project\":\"%s\",\"pattern\":\"%s\"}", target_tmp, + dep_pattern); + char *raw3 = cbm_mcp_handle_tool(srv, "search_graph", args3); + *out_dep_resp = extract_text(raw3); + free(raw3); +} + +TEST(path_project_autoindex_indexes_dependencies) { + char session_tmp[256]; + snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_dep_sess_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp)); + ASSERT_EQ(th_write_file(TH_PATH(session_tmp, "main.c"), "void session_dep_fn(void) {}\n"), + 0); + + char target_tmp[256]; + snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_dep_tgt_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp)); + ASSERT_EQ(setup_path_dep_target(target_tmp), 0); + + char cfg_tmp[256]; + snprintf(cfg_tmp, sizeof(cfg_tmp), "/tmp/cbm_path_depon_cfg_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cfg_tmp)); + cbm_config_t *cfg = cbm_config_open(cfg_tmp); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "true"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + const char *old_auto_index = getenv("CBM_AUTO_INDEX"); + char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL; + cbm_setenv("CBM_AUTO_INDEX", "true", 1); + + bool project_indexed = false; + char *dep_resp = NULL; + run_path_dep_queries(srv, session_tmp, target_tmp, "path_dep_sentinel", + &project_indexed, &dep_resp); + bool dep_indexed = dep_resp && strstr(dep_resp, "path_dep_sentinel") != NULL; + free(dep_resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (old_auto_index_copy) { + cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1); + free(old_auto_index_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + th_cleanup(cfg_tmp); + th_cleanup(target_tmp); + th_cleanup(session_tmp); + + ASSERT_TRUE(project_indexed); + /* The enabled preset path must run the same dependency indexing helper as + * session-root indexing. */ + ASSERT_TRUE(dep_indexed); + PASS(); +} + +TEST(path_project_autoindex_deps_disabled_by_default) { + char session_tmp[256]; + snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_depoff_sess_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp)); + ASSERT_EQ(th_write_file(TH_PATH(session_tmp, "main.c"), "void session_dep_fn(void) {}\n"), + 0); + + char target_tmp[256]; + snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_depoff_tgt_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp)); + ASSERT_EQ(setup_path_dep_target(target_tmp), 0); + + char cfg_tmp[256]; + snprintf(cfg_tmp, sizeof(cfg_tmp), "/tmp/cbm_path_depoff_cfg_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cfg_tmp)); + cbm_config_t *cfg = cbm_config_open(cfg_tmp); + ASSERT_NOT_NULL(cfg); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + const char *old_auto_index = getenv("CBM_AUTO_INDEX"); + char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL; + cbm_setenv("CBM_AUTO_INDEX", "true", 1); + + bool project_indexed = false; + char *dep_resp = NULL; + run_path_dep_queries(srv, session_tmp, target_tmp, "path_dep_sentinel", + &project_indexed, &dep_resp); + bool dep_indexed = dep_resp && strstr(dep_resp, "path_dep_sentinel") != NULL; + free(dep_resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (old_auto_index_copy) { + cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1); + free(old_auto_index_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + th_cleanup(cfg_tmp); + th_cleanup(target_tmp); + th_cleanup(session_tmp); + + ASSERT_TRUE(project_indexed); + /* The product default must keep automatic path indexing dep-free. */ + ASSERT_FALSE(dep_indexed); + PASS(); +} + +TEST(path_project_autoindex_honors_dep_limit_and_refreshes_rank) { + char session_tmp[256]; + snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_depcap_sess_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp)); + ASSERT_EQ(th_write_file(TH_PATH(session_tmp, "main.c"), "void session_dep_fn(void) {}\n"), + 0); + + char target_tmp[256]; + snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_depcap_tgt_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp)); + ASSERT_EQ(th_write_file(TH_PATH(target_tmp, "Makefile"), "all:\n\tcc upstream.c\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(target_tmp, "upstream.c"), + "void path_dep_upstream_fn(void) {}\n"), + 0); + char vendor_a[512]; + snprintf(vendor_a, sizeof(vendor_a), "%s/vendor/liba", target_tmp); + ASSERT_EQ(th_mkdir_p(vendor_a), 0); + ASSERT_EQ(th_write_file(TH_PATH(vendor_a, "liba.c"), + "int path_dep_cap_a(void) { return 1; }\n"), + 0); + char vendor_b[512]; + snprintf(vendor_b, sizeof(vendor_b), "%s/vendor/libb", target_tmp); + ASSERT_EQ(th_mkdir_p(vendor_b), 0); + ASSERT_EQ(th_write_file(TH_PATH(vendor_b, "libb.c"), + "int path_dep_cap_b(void) { return 1; }\n"), + 0); + + char cfg_tmp[256]; + snprintf(cfg_tmp, sizeof(cfg_tmp), "/tmp/cbm_path_depcap_cfg_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cfg_tmp)); + cbm_config_t *cfg = cbm_config_open(cfg_tmp); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "true"), 0); + ASSERT_EQ(cbm_config_set(cfg, "auto_dep_limit", "1"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, CBM_RANK_REFRESH_AT_PUBLISH), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + const char *old_auto_index = getenv("CBM_AUTO_INDEX"); + char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL; + cbm_setenv("CBM_AUTO_INDEX", "true", 1); + + bool project_indexed = false; + char *dep_resp = NULL; + /* "path_dep_cap" matches both vendored sentinels by substring. */ + run_path_dep_queries(srv, session_tmp, target_tmp, "path_dep_cap", + &project_indexed, &dep_resp); + bool dep_a = dep_resp && strstr(dep_resp, "path_dep_cap_a") != NULL; + bool dep_b = dep_resp && strstr(dep_resp, "path_dep_cap_b") != NULL; + free(dep_resp); + + char *target_project = cbm_project_name_from_path(target_tmp); + cbm_store_t *published_store = target_project ? cbm_store_open(target_project) : NULL; + bool rank_complete = + published_store && cbm_pagerank_views_complete(published_store, target_project); + cbm_store_close(published_store); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (old_auto_index_copy) { + cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1); + free(old_auto_index_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + free(target_project); + th_cleanup(cfg_tmp); + th_cleanup(target_tmp); + th_cleanup(session_tmp); + + ASSERT_TRUE(project_indexed); + /* auto_dep_limit=1 with two discovered vendored deps: exactly one must + * be indexed. RED before the fix: neither is (deps never ran). */ + ASSERT_TRUE(dep_a != dep_b); + /* Sync auto-index owns the dependency pass after initial publication. + * Its at-publish contract must therefore leave all rank views complete, + * matching explicit and background publication. */ + ASSERT_TRUE(rank_complete); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Self-healing hint: a zero-result dependency-scoped search on a server + * where auto_index_deps is disabled must name index_dependencies as the + * corrective action (established hint style: commit b2c4c4e8). + * ══════════════════════════════════════════════════════════════════ */ + +TEST(dep_search_hint_names_index_dependencies_when_deps_disabled) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "validation-test"); + + cbm_config_t *cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "false"), 0); + cbm_mcp_server_set_config(srv, cfg); + + /* "deps" expands to "validation-test.dep" (prefix match, zero rows). + * format=json pins the JSON body so the hint key is directly greppable. */ + char *raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"deps\",\"pattern\":\"any_dep_symbol\",\"format\":\"json\"}"); + char *resp = extract_text(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* RED before the fix: the hint described build systems but never named + * the tool that fixes the situation. */ + ASSERT_NOT_NULL(strstr(resp, "index_dependencies")); + ASSERT_NOT_NULL(strstr(resp, "auto_index_deps")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Regression: classic tool names still work + * ══════════════════════════════════════════════════════════════════ */ + +TEST(regression_trace_path_tool_name_still_works) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"foo\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "unknown tool")); /* must not reject classic name */ + free(resp); + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Config: context_injection=false disables _context header + * + * context_injection (default true) / CBM_CONTEXT_INJECTION controls + * whether inject_context_once embeds the _context header in the first + * tool response. Disabling saves tokens in scripted/programmatic use. + * ══════════════════════════════════════════════════════════════════ */ + +TEST(config_context_injection_disabled) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_config_t *cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(cfg); + cbm_config_set(cfg, "context_injection", "false"); + cbm_mcp_server_set_config(srv, cfg); + + /* With context_injection=false, _context must NOT appear in any call */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"_context\":")); + free(resp); + + /* Second call also no _context */ + raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}"); + resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"_context\":")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + cleanup_validation_dir(tmp); + PASS(); +} + +TEST(config_context_injection_enabled_by_default) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* No config set → default is true → _context present on first call. + * format=json: pins the legacy JSON _context shape; default_response_format + * is toon, which delivers the same facts as native _context_* TOON fields. */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3,\"format\":\"json\"}"); + char *resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"_context\":")); + free(resp); + + /* Second call: _context deduped (context_injected=true) */ + raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3,\"format\":\"json\"}"); + resp = extract_text(raw); free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"_context\":")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_validation_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * Suite registration + * ══════════════════════════════════════════════════════════════════ */ + +void suite_input_validation(void) { + RUN_TEST(f1_empty_label_returns_results); + RUN_TEST(f6_invalid_sort_by_errors); + RUN_TEST(f6_sort_by_typo_errors); + RUN_TEST(f9_invalid_regex_errors); + RUN_TEST(f9_valid_regex_succeeds); + RUN_TEST(f6_sort_by_calls_accepted); + RUN_TEST(f6_sort_by_linkrank_accepted); + RUN_TEST(f9_glob_star_autoconverted); + RUN_TEST(f9_glob_question_autoconverted); + RUN_TEST(f9_valid_regex_shaped_glob_is_normalized_before_compile); + RUN_TEST(f9_valid_regex_still_works); + RUN_TEST(f9_explicit_group_and_class_regex_quantifiers_stay_regex); + RUN_TEST(f9_truly_invalid_pattern_still_errors); + RUN_TEST(f9_qn_pattern_glob_autoconverted); + RUN_TEST(f10_negative_depth_returns_results); + RUN_TEST(trace_case_mismatch_finds_via_fallback); + RUN_TEST(trace_exact_match_still_works); + RUN_TEST(trace_truly_missing_still_errors); + RUN_TEST(f15_invalid_direction_errors); + RUN_TEST(f15_valid_direction_succeeds); + RUN_TEST(trace_invalid_mode_errors); + RUN_TEST(g1_summary_mode_has_results_key); + RUN_TEST(cq3_cypher_with_label_rejected); + RUN_TEST(ix2_status_resource_format); + RUN_TEST(pattern_or_search_graph); + RUN_TEST(pattern_or_search_graph_normalizes_valid_regex_shaped_glob); + RUN_TEST(source_search_via_search_in_param); + RUN_TEST(source_search_path_project_normalizes_to_slug); + RUN_TEST(source_search_default_is_graph); + RUN_TEST(summary_bool_alias); + RUN_TEST(case_sensitive_graph_search); + RUN_TEST(config_compact_default_false); + RUN_TEST(config_response_format_json_with_toon_override); + RUN_TEST(toon_first_response_context_is_native_toon); + RUN_TEST(toon_plain_text_error_separates_first_response_context); + RUN_TEST(toon_context_injection_config_is_respected); + RUN_TEST(graph_schema_formats_preserve_bounded_facts); + RUN_TEST(config_default_sort_by_calls); + RUN_TEST(trace_accepts_qualified_name_param); + RUN_TEST(pattern_glob_wildcards_auto_convert); + RUN_TEST(pattern_invalid_regex_returns_error); + RUN_TEST(trace_qn_takes_priority_over_function_name); + RUN_TEST(trace_qn_not_found_returns_specific_hint); + RUN_TEST(detect_changes_slug_project_finds_root); + RUN_TEST(manage_adr_slug_project_finds_root); + RUN_TEST(source_search_tilde_project_expands); + RUN_TEST(graph_search_tilde_project_autoindexes); + RUN_TEST(source_search_no_project_falls_back_to_session); + RUN_TEST(path_project_auto_indexes_separate_directory); + RUN_TEST(path_project_autoindex_respects_file_limit); + RUN_TEST(path_project_autoindex_indexes_dependencies); + RUN_TEST(path_project_autoindex_deps_disabled_by_default); + RUN_TEST(path_project_autoindex_honors_dep_limit_and_refreshes_rank); + RUN_TEST(dep_search_hint_names_index_dependencies_when_deps_disabled); + RUN_TEST(regression_trace_path_tool_name_still_works); + RUN_TEST(config_context_injection_disabled); + RUN_TEST(config_context_injection_enabled_by_default); +} diff --git a/tests/test_integration.c b/tests/test_integration.c index 532b33457..f8ebc544f 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -21,6 +21,7 @@ #include #include #include +#include /* ── Test fixture: temp project with Python + Go files ─────────── */ @@ -370,7 +371,7 @@ TEST(integ_mcp_trace_path) { char args[256]; snprintf(args, sizeof(args), "{\"function_name\":\"Compute\",\"project\":\"%s\"," - "\"direction\":\"outbound\",\"max_depth\":3}", + "\"direction\":\"outbound\",\"depth\":3}", g_project); char *resp = call_tool("trace_path", args); @@ -568,6 +569,9 @@ TEST(integ_store_bfs_traversal) { /* BFS outbound from Multiply */ cbm_traverse_result_t trav = {0}; int rc = cbm_store_bfs(store, results[0].id, "outbound", NULL, 0, 3, 20, &trav); + if (rc != CBM_STORE_OK) { + fprintf(stderr, "integ_store_bfs_traversal: %s\n", cbm_store_error(store)); + } ASSERT_EQ(rc, CBM_STORE_OK); /* Should visit at least Add */ ASSERT_TRUE(trav.visited_count >= 0); /* might be 0 if no edges */ @@ -622,7 +626,11 @@ TEST(store_bfs_edges_survive_large_visited_set) { } cbm_traverse_result_t tr = {0}; - ASSERT_EQ(cbm_store_bfs(s, hub_id, "inbound", NULL, 0, 1, SPOKES + 10, &tr), CBM_STORE_OK); + int bfs_rc = cbm_store_bfs(s, hub_id, "inbound", NULL, 0, 1, SPOKES + 10, &tr); + if (bfs_rc != CBM_STORE_OK) { + fprintf(stderr, "store_bfs_edges_survive_large_visited_set: %s\n", cbm_store_error(s)); + } + ASSERT_EQ(bfs_rc, CBM_STORE_OK); ASSERT_EQ(tr.visited_count, SPOKES); /* Every caller->hub edge must be collected — none silently dropped. */ ASSERT_EQ(tr.edge_count, SPOKES); @@ -708,6 +716,68 @@ TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop) { PASS(); } +/* "both" is a real undirected impact view, not an alias for outbound. The + * temporary seed rows are cleared before return so a later traversal on the + * same store cannot inherit another request's anchors. */ +TEST(store_bfs_multi_both_traverses_callers_and_callees_and_clears_seeds) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "impact_both", "/tmp/impact_both"), CBM_STORE_OK); + + const char *names[] = {"caller", "seed", "callee"}; + int64_t ids[3]; + for (int i = 0; i < 3; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "impact_both.%s", names[i]); + cbm_node_t node = {.project = "impact_both", + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "both.c", + .start_line = 1, + .end_line = 2}; + ids[i] = cbm_store_upsert_node(s, &node); + ASSERT_GT(ids[i], 0); + } + cbm_edge_t inbound = {.project = "impact_both", + .source_id = ids[0], + .target_id = ids[1], + .type = "CALLS"}; + cbm_edge_t outbound = {.project = "impact_both", + .source_id = ids[1], + .target_id = ids[2], + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &inbound), 0); + ASSERT_GT(cbm_store_insert_edge(s, &outbound), 0); + + cbm_traverse_result_t tr = {0}; + bool truncated = true; + ASSERT_EQ(cbm_store_bfs_multi(s, &ids[1], 1, "both", NULL, 0, 1, 10, &tr, &truncated), + CBM_STORE_OK); + ASSERT_FALSE(truncated); + ASSERT_EQ(tr.visited_count, 2); + bool saw_caller = false; + bool saw_callee = false; + for (int i = 0; i < tr.visited_count; i++) { + saw_caller = saw_caller || tr.visited[i].node.id == ids[0]; + saw_callee = saw_callee || tr.visited[i].node.id == ids[2]; + ASSERT_EQ(tr.visited[i].hop, 1); + } + ASSERT_TRUE(saw_caller); + ASSERT_TRUE(saw_callee); + cbm_store_traverse_free(&tr); + + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(s), "SELECT COUNT(*) FROM temp.bfs_seeds", -1, + &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int(stmt, 0), 0); + sqlite3_finalize(stmt); + cbm_store_close(s); + PASS(); +} + /* The memory-safety ceiling reports truncation instead of silently capping. * A star of N callees from one seed, ceiling = N/2, must return exactly N/2 * rows with *truncated = true. */ @@ -840,6 +910,7 @@ SUITE(integration) { RUN_TEST(integ_store_bfs_traversal); RUN_TEST(store_bfs_edges_survive_large_visited_set); RUN_TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop); + RUN_TEST(store_bfs_multi_both_traverses_callers_and_callees_and_clears_seeds); RUN_TEST(store_bfs_multi_reports_truncation_at_ceiling); /* Pipeline API tests (no db needed) */ diff --git a/tests/test_java_lsp.c b/tests/test_java_lsp.c index 69fcc3c04..88643b2bd 100644 --- a/tests/test_java_lsp.c +++ b/tests/test_java_lsp.c @@ -53,6 +53,22 @@ static int find_resolved(const CBMFileResult *r, const char *callerSub, const ch return -1; } +static int find_resolved_strategy(const CBMFileResult *r, const char *callerSub, + const char *calleeSub, const char *strategy) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->confidence < 0.5f) { + continue; + } + if (rc->caller_qn && rc->callee_qn && rc->strategy && + strstr(rc->caller_qn, callerSub) && strstr(rc->callee_qn, calleeSub) && + strcmp(rc->strategy, strategy) == 0) { + return i; + } + } + return -1; +} + static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { int idx = find_resolved(r, callerSub, calleeSub); if (idx < 0) { @@ -760,6 +776,21 @@ TEST(jlsp_static_import_method) { PASS(); } +TEST(jlsp_static_import_package_class_short_name) { + const char *src = + "package demo;\n" + "import static demo.Util.twice;\n" + "class Util { static int twice(int x) { return x + x; } }\n" + "public class Main {\n" + " public int run(int x) { return twice(x); }\n" + "}\n"; + CBMFileResult *r = extract_java(src); + ASSERT_NOT_NULL(r); + ASSERT_GTE(find_resolved_strategy(r, "run", "Util.twice", "lsp_static_import"), 0); + cbm_free_result(r); + PASS(); +} + TEST(jlsp_on_demand_import) { const char *src = "import java.util.*;\n" @@ -1841,6 +1872,7 @@ void suite_java_lsp(void) { /* Imports */ RUN_TEST(jlsp_static_import_method); + RUN_TEST(jlsp_static_import_package_class_short_name); RUN_TEST(jlsp_on_demand_import); /* Generics */ diff --git a/tests/test_kotlin_lsp.c b/tests/test_kotlin_lsp.c index ed0f6cf22..6b076af8a 100644 --- a/tests/test_kotlin_lsp.c +++ b/tests/test_kotlin_lsp.c @@ -44,6 +44,17 @@ static CBMFileResult *extract_kotlin_path(const char *source, const char *rel_pa NULL, NULL); } +static bool has_def_qn_label(const CBMFileResult *r, const char *qn, const char *label) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->qualified_name && d->label && strcmp(d->qualified_name, qn) == 0 && + strcmp(d->label, label) == 0) { + return true; + } + } + return false; +} + /* Search resolved_calls for a match where caller contains callerSub * and callee contains calleeSub. Returns index or -1. */ static int find_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { @@ -448,6 +459,19 @@ TEST(ktlsp_typealias) { PASS(); } +TEST(ktlsp_any_builtin_targets) { + CBMFileResult *r = extract_kotlin("fun show(value: Any): String = value.toString()\n"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_GTE(require_resolved(r, "show", "kotlin.Any.toString"), 0); + ASSERT(has_def_qn_label(r, "kotlin.Any", "Class")); + ASSERT(has_def_qn_label(r, "kotlin.Any.toString", "Method")); + ASSERT(has_def_qn_label(r, "kotlin.Any.equals", "Method")); + ASSERT(has_def_qn_label(r, "kotlin.Any.hashCode", "Method")); + cbm_free_result(r); + PASS(); +} + /* ── 14. Enums ────────────────────────────────────────────── */ TEST(ktlsp_enum_class) { @@ -1134,6 +1158,7 @@ SUITE(kotlin_lsp) { RUN_TEST(ktlsp_scope_let); RUN_TEST(ktlsp_scope_apply); RUN_TEST(ktlsp_typealias); + RUN_TEST(ktlsp_any_builtin_targets); RUN_TEST(ktlsp_enum_class); RUN_TEST(ktlsp_sealed_when); RUN_TEST(ktlsp_generic_call); diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 3ce639ded..49e9329b1 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -72,17 +73,10 @@ static cbm_store_t *lang_open_indexed(LangProj *lp) { return NULL; } char cache_dir[512]; - const char *configured_cache = getenv("CBM_CACHE_DIR"); - if (configured_cache && configured_cache[0]) { - snprintf(cache_dir, sizeof(cache_dir), "%s", configured_cache); - } else { - const char *home = getenv("HOME"); - if (!home) { - home = "/tmp"; - } - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); - } - cbm_mkdir_p(cache_dir, 0755); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); + cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); lp->srv = cbm_mcp_server_new(NULL); @@ -243,7 +237,16 @@ static bool extract_crashes(const char *content, CBMLanguage lang, const char *r _exit(0); } int status = 0; - (void)waitpid(pid, &status, 0); + /* leaks --atExit (macOS) SIGSTOPs the forked child during heap inspection; + * WUNTRACED+SIGCONT avoids the hang (mirrors test_store_bulk.c, b336466). */ + for (;;) { + if (waitpid(pid, &status, WUNTRACED) < 0) break; + if (WIFSTOPPED(status)) { + kill(pid, SIGCONT); + continue; + } + break; + } return WIFSIGNALED(status); #endif } @@ -297,6 +300,41 @@ TEST(contract_c_calls_attributed_to_function) { PASS(); } +TEST(contract_c_nested_calls_keep_enclosing_function_qn) { + const char *src = "static int is_streamlined_default_tool(const char *name) {\n" + " return name && name[0];\n" + "}\n" + "static void emit_tool(int i) { (void)i; }\n" + "char *cbm_mcp_tools_list(void *srv) {\n" + " (void)srv;\n" + " for (int i = 0; i < 3; i++) {\n" + " if (is_streamlined_default_tool(\"search_graph\")) {\n" + " emit_tool(i);\n" + " }\n" + " }\n" + " return 0;\n" + "}\n"; + CBMFileResult *r = + cbm_extract_file(src, (int)strlen(src), CBM_LANG_C, "lc", "mcp.c", 0, NULL, NULL); + ASSERT_NOT_NULL(r); + + int scoped_calls = 0; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (!call->callee_name || !call->enclosing_func_qn) { + continue; + } + if ((strcmp(call->callee_name, "is_streamlined_default_tool") == 0 || + strcmp(call->callee_name, "emit_tool") == 0) && + strstr(call->enclosing_func_qn, "cbm_mcp_tools_list")) { + scoped_calls++; + } + } + cbm_free_result(r); + ASSERT_GTE(scoped_calls, 2); + PASS(); +} + /* Java: extraction must not crash on a real-world construct mix (enhanced-for + * method reference + method chain + pattern instanceof) — reproduces the SIGBUS. */ static const char *JAVA_SRC = "package zip;\n" @@ -630,7 +668,7 @@ static const CallCase CALL_CASES[] = { "print(value)\n}\n", true, NULL}, {"dart", "a.dart", "void helper() {\n print('helper');\n}\n\nvoid run() {\n helper();\n}\n", - false, "selector call node carries no callee field; no dart branch in extract_calls.c"}, + true, NULL}, {"scala", "a.scala", "def helper(): Int =\n 21 + 21\n\ndef run(): Int =\n helper() * 2\n", true, NULL}, {"bash", "a.sh", "helper() {\n echo \"doing work\"\n}\n\nrun() {\n helper\n}\n", true, NULL}, @@ -818,8 +856,8 @@ TEST(contract_calls_breadth) { } } fprintf(stderr, - " [CALLS-BREADTH] %d langs: %d FAILURES (each = a language that does not " - "resolve a same-file CALLS edge)\n", + " [CALLS-BREADTH] %d languages checked; observed gaps=%d " + "(gap = no same-file CALLS edge)\n", n, failures); ASSERT_EQ(failures, 0); PASS(); @@ -902,13 +940,18 @@ TEST(contract_edge_defines) { PASS(); } -/* DEFINES_METHOD — Class -> Method when the method's parent_class resolves. */ +/* DEFINES_METHOD — Class -> Method when the method's parent_class resolves. + * MEMBER_OF — the reverse Method -> Class edge (fork addition; consumed by + * pagerank.c member_rank_factor). Asserting BOTH directions locks in the + * function<->class tie (§4c): a class-scoped callable must link to its class + * both ways. The reverse edge previously had no test coverage anywhere. */ TEST(contract_edge_defines_method) { static const LangFile f[] = {{"greeter.py", "class Greeter:\n def hello(self):\n return \"hi\"\n\n" " def bye(self):\n return \"bye\"\n\n\n" "def main():\n g = Greeter()\n return g.hello()\n"}}; - ASSERT_TRUE(edge_present(f, 1, "DEFINES_METHOD", 1)); /* Greeter.hello, Greeter.bye */ + ASSERT_TRUE(edge_present(f, 1, "DEFINES_METHOD", 1)); /* Class -> Method: Greeter.hello, Greeter.bye */ + ASSERT_TRUE(edge_present(f, 1, "MEMBER_OF", 1)); /* Method -> Class: reverse edge feeding PageRank */ PASS(); } @@ -1447,6 +1490,27 @@ TEST(contract_edge_commonjs_require_call_resolves_issue871) { PASS(); } +/* A weak short-name call match must not bind executable source to a Variable + * extracted from an unrelated data file. This exact shape previously made + * incremental and clean benchmark graphs depend on registry insertion order. */ +TEST(contract_call_weak_match_rejects_noncallable_data_symbol) { + LangProj lp; + static const LangFile f[] = { + {"caller.c", "void caller(void) {\n format();\n}\n"}, + {"benchmarks/schema/example.schema.json", + "{\"type\":\"object\",\"properties\":{\"format\":{\"type\":\"string\"}}}\n"}}; + cbm_store_t *store = lang_index_files(&lp, f, 2); + ASSERT_TRUE(store != NULL); + int false_target = calls_edge_targets(store, lp.project, "Variable", ".format"); + if (false_target) { + fprintf(stderr, + " weak call resolution must not target unrelated JSON Variable `format`\n"); + } + ASSERT_TRUE(!false_target); + lang_cleanup(&lp, store); + PASS(); +} + /* DEPENDS_ON — Helm Chart.yaml `dependencies:` -> per-dependency Chart node. * Basename must be exactly "Chart.yaml"; pass_k8s runs in both pipeline paths. */ TEST(contract_edge_depends_on) { @@ -1615,6 +1679,7 @@ SUITE(lang_contract) { * tier; these fast contracts still guard against regressions. */ RUN_TEST(contract_kotlin_imports_extracted); RUN_TEST(contract_c_calls_attributed_to_function); + RUN_TEST(contract_c_nested_calls_keep_enclosing_function_qn); RUN_TEST(contract_java_extract_no_crash); /* Rich per-language invariants (P3). */ @@ -1660,6 +1725,7 @@ SUITE(lang_contract) { RUN_TEST(contract_edge_no_infra_routes_from_ci_configs_issue999); RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted); RUN_TEST(contract_edge_commonjs_require_call_resolves_issue871); + RUN_TEST(contract_call_weak_match_rejects_noncallable_data_symbol); RUN_TEST(contract_edge_depends_on); RUN_TEST(contract_edge_parallel_service_edges); RUN_TEST(contract_edge_file_changes_with); diff --git a/tests/test_log.c b/tests/test_log.c index 562768567..6297a520b 100644 --- a/tests/test_log.c +++ b/tests/test_log.c @@ -27,7 +27,14 @@ static int saved_stderr; static int pipe_fds[2]; static void test_log_sink(const char *line) { - snprintf(sink_buf, sizeof(sink_buf), "%s", line ? line : ""); + if (!line) { + return; + } + size_t used = strlen(sink_buf); + if (used >= sizeof(sink_buf) - 1) { + return; + } + snprintf(sink_buf + used, sizeof(sink_buf) - used, "%s\n", line); } static void capture_start(void) { @@ -117,6 +124,40 @@ TEST(log_int_helper) { PASS(); } +TEST(log_profile_mirror_is_opt_in_and_prof_only) { + cbm_log_set_level(CBM_LOG_DEBUG); + cbm_log_set_profile_stderr_mirror(false); + sink_buf[0] = '\0'; + cbm_log_set_sink(test_log_sink); + + capture_start(); + cbm_log_info("prof", "phase", "unit", "sub", "sink_only"); + const char *output = capture_end(); + ASSERT_EQ(strlen(output), 0); + ASSERT(cbm_str_contains_raw(sink_buf, "msg=prof")); + + sink_buf[0] = '\0'; + cbm_log_set_profile_stderr_mirror(true); + capture_start(); + cbm_log_info("prof", "phase", "unit", "sub", "mirrored"); + output = capture_end(); + ASSERT(cbm_str_contains_raw(output, "msg=prof")); + ASSERT(cbm_str_contains_raw(output, "sub=mirrored")); + ASSERT(cbm_str_contains_raw(sink_buf, "msg=prof")); + + sink_buf[0] = '\0'; + capture_start(); + cbm_log_info("not.prof", "key", "value"); + output = capture_end(); + ASSERT_EQ(strlen(output), 0); + ASSERT(cbm_str_contains_raw(sink_buf, "msg=not.prof")); + + cbm_log_set_sink(NULL); + cbm_log_set_profile_stderr_mirror(false); + cbm_log_set_level(CBM_LOG_INFO); + PASS(); +} + TEST(log_json_output) { cbm_log_set_level(CBM_LOG_DEBUG); cbm_log_set_format(CBM_LOG_FORMAT_JSON); @@ -207,7 +248,6 @@ TEST(log_format_unset_keeps_current) { cbm_log_set_format(CBM_LOG_FORMAT_TEXT); cbm_log_init_from_env(); ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT); - PASS(); } @@ -290,6 +330,7 @@ SUITE(log) { RUN_TEST(log_filtered_by_level); RUN_TEST(log_error_output); RUN_TEST(log_int_helper); + RUN_TEST(log_profile_mirror_is_opt_in_and_prof_only); RUN_TEST(log_json_output); RUN_TEST(log_text_sanitizes_control_chars); RUN_TEST(log_sink_tee_keeps_stderr); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index cac76c41b..6500b706c 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -127,7 +127,9 @@ static cbm_store_t *lrp_open_indexed(LRP_Proj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); @@ -216,9 +218,9 @@ static int lrp_assert_calls(const LRP_File *files, int nfiles, int min_calls, scenario, got, min_calls, expect_green ? "(GREEN regression)" : "(RED reproduction)"); lrp_diag(store, lp.project, scenario); } else if (!expect_green) { - /* Unexpectedly passing — the lsp_cross wiring may have been added. */ - fprintf(stderr, " [LRP] %s UNEXPECTED PASS calls=%d " - "(lsp_cross may now be wired — promote to GREEN)\n", scenario, got); + fprintf(stderr, + " [LRP] %s CAPABILITY PRESENT (baseline expected absent) calls=%d\n", + scenario, got); } lrp_cleanup(&lp, store); return got >= min_calls; diff --git a/tests/test_main.c b/tests/test_main.c index 6d6c3b3d1..8f49e691e 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -7,19 +7,22 @@ int tf_pass_count = 0; int tf_fail_count = 0; int tf_skip_count = 0; +int tf_filter_count = 0; #include "test_framework.h" #include "test_helpers.h" +#include "foundation/constants.h" +#include "foundation/profile.h" +#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ +#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ +#include "foundation/mem.h" /* cbm_mem_init — worker budget */ +#include "foundation/platform.h" /* system RAM-aware worker budget */ +#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ +#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ #include "test_daemon_runtime_contract.h" -#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ -#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ -#include "foundation/mem.h" /* cbm_mem_init — worker budget */ -#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ #include "daemon/runtime.h" /* bounded worker response probe */ #include "daemon/ipc.h" /* Windows private-lock re-exec probe */ #include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ -#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ -#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ #include #include #include @@ -274,6 +277,7 @@ static int tf_maybe_run_index_worker(int argc, char **argv) { if (!srv) { return 1; } + cbm_mcp_server_set_response_context(srv, false); char *result = cbm_mcp_handle_tool(srv, "index_repository", invocation.args_json); if (result) { const char *ro = cbm_index_worker_response_out(); @@ -439,6 +443,9 @@ static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { if (argc != 6 || strcmp(argv[1], "__cbm_runtime_hello_client") != 0) { return -1; } +#ifndef _WIN32 + (void)alarm(TF_RUNTIME_IMAGE_WATCHDOG_SECONDS); +#endif cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_ipc_endpoint_new(argv[3], argv[2]); cbm_daemon_build_identity_t identity = { .semantic_version = argv[4], @@ -468,6 +475,9 @@ static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { if (argc != 7 || strcmp(argv[1], "__cbm_runtime_activation_client") != 0) { return -1; } +#ifndef _WIN32 + (void)alarm(TF_RUNTIME_IMAGE_WATCHDOG_SECONDS); +#endif char *action_end = NULL; unsigned long action_value = strtoul(argv[6], &action_end, 10); bool action_valid = action_end != argv[6] && *action_end == '\0' && @@ -544,8 +554,15 @@ static int tf_maybe_run_mcp_idxfailclosed_probe(int argc, char **argv) { static int g_suite_argc = 0; static char **g_suite_argv = NULL; static bool *g_suite_arg_matched = NULL; +static const char *g_suite_env_filter = NULL; +static bool g_suite_env_matched = false; static bool suite_requested(const char *name) { + if (g_suite_env_filter) { + bool requested = strstr(name, g_suite_env_filter) != NULL; + g_suite_env_matched = g_suite_env_matched || requested; + return requested; + } if (g_suite_argc <= 1) { return true; } @@ -575,12 +592,16 @@ static bool g_list_only = false; * the shard union guard stays consistent. */ static bool g_skip_perf = false; +/* These two spell the suite name exactly once, into BOTH the list branch and + * the run branch, which is what makes --list-suites incapable of drifting from + * what executes. They call TF_RUN_SUITE_RAW rather than RUN_SUITE so the + * full-run path below can poison RUN_SUITE without disabling them. */ #define RUN_SELECTED_SUITE(name) \ do { \ if (g_list_only) { \ printf("%s\n", #name); \ } else if (suite_requested(#name)) { \ - RUN_SUITE(name); \ + TF_RUN_SUITE_RAW(name); \ } \ } while (0) @@ -592,7 +613,7 @@ static bool g_skip_perf = false; if (g_list_only) { \ printf("%s\n", #name); \ } else if (suite_requested(#name)) { \ - RUN_SUITE(name); \ + TF_RUN_SUITE_RAW(name); \ } \ } while (0) @@ -619,6 +640,7 @@ extern void suite_ac(void); extern void suite_store_nodes(void); extern void suite_store_edges(void); extern void suite_store_search(void); +extern void suite_store_bulk(void); extern void suite_cypher(void); extern void suite_mcp(void); extern void suite_mcp_mutation_guard(void); @@ -668,7 +690,7 @@ extern void suite_java_lsp_coverage(void); extern void suite_kotlin_lsp(void); extern void suite_rust_lsp(void); extern void suite_store_arch(void); -extern void suite_store_bulk(void); +extern void suite_httplink(void); extern void suite_store_pragmas(void); extern void suite_store_checkpoint(void); extern void suite_traces(void); @@ -687,6 +709,11 @@ extern void suite_worker_pool(void); extern void suite_parallel(void); extern void suite_mem(void); extern void suite_ui(void); +extern void suite_token_reduction(void); +extern void suite_depindex(void); +extern void suite_pagerank(void); +extern void suite_tool_consolidation(void); +extern void suite_input_validation(void); extern void suite_httpd(void); extern void suite_security(void); extern void suite_yaml(void); @@ -717,12 +744,125 @@ extern void suite_stack_overflow_b(void); extern void suite_stack_overflow_c(void); extern void suite_dump_verify(void); extern void suite_dump_verify_io(void); +extern void suite_schema_declared_property_keys(void); /* Free the main thread's thread-local node-type bitset cache before exit so * LeakSanitizer (Linux x64) doesn't report it. Worker threads free their own * caches at thread teardown (pass_parallel.c). */ extern void cbm_kind_in_set_free_cache(void); +/* Capacity for the per-run isolated cache dir path. */ +#define TEST_CACHE_DIR_CAP CBM_PATH_MAX +/* cbm_setenv() overwrite flag: nonzero = replace an existing value. */ +#define ENV_OVERWRITE 1 +/* Test-only injection used to prove cleanup failures make the runner red. */ +#define TEST_CACHE_CLEANUP_FAIL_ENV "CBM_TEST_FAIL_CACHE_CLEANUP" +/* Existing integration-test artifact root: setting it opts failed runs into + * retaining their isolated cache alongside other diagnostic evidence. */ +#define TEST_ARTIFACT_DIR_ENV "CBM_TEST_ARTIFACT_DIR" + +static char test_cache_dir[TEST_CACHE_DIR_CAP]; +static char test_repository_root[CBM_PATH_MAX]; + +static bool tf_source_checkout_at(const char *candidate) { + if (!candidate || !candidate[0]) { + return false; + } + char makefile_path[CBM_PATH_MAX]; + char fixture_path[CBM_PATH_MAX]; + int makefile_written = + snprintf(makefile_path, sizeof(makefile_path), "%s/Makefile.cbm", candidate); + int fixture_written = snprintf(fixture_path, sizeof(fixture_path), + "%s/vendored/xxhash/xxhash.h", candidate); + return makefile_written > 0 && (size_t)makefile_written < sizeof(makefile_path) && + fixture_written > 0 && (size_t)fixture_written < sizeof(fixture_path) && + cbm_file_exists(makefile_path) && cbm_file_exists(fixture_path); +} + +static bool tf_find_source_checkout_upward(char *candidate) { + while (candidate && candidate[0]) { + if (tf_source_checkout_at(candidate)) { + return true; + } + char *slash = strrchr(candidate, '/'); + char *backslash = strrchr(candidate, '\\'); + if (backslash && (!slash || backslash > slash)) { + slash = backslash; + } + if (!slash) { + break; + } + if (slash == candidate) { + candidate[1] = '\0'; + return tf_source_checkout_at(candidate); + } + *slash = '\0'; + } + return false; +} + +static void tf_capture_repository_root(const char *runner_path) { + test_repository_root[0] = '\0'; + if (runner_path && runner_path[0] && + cbm_canonical_path(runner_path, test_repository_root, sizeof(test_repository_root))) { + char *slash = strrchr(test_repository_root, '/'); + char *backslash = strrchr(test_repository_root, '\\'); + if (backslash && (!slash || backslash > slash)) { + slash = backslash; + } + if (slash) { + *slash = '\0'; + if (tf_find_source_checkout_upward(test_repository_root)) { + return; + } + } + } + if (!cbm_canonical_path(".", test_repository_root, sizeof(test_repository_root)) || + !tf_find_source_checkout_upward(test_repository_root)) { + test_repository_root[0] = '\0'; + } +} + +const char *tf_repository_root(void) { + return test_repository_root[0] ? test_repository_root : NULL; +} + +static int cleanup_test_cache(void) { + if (!test_cache_dir[0]) { + return 0; + } + /* Retention is explicit. Ordinary red and green runs both clean the exact + * runner-owned root; CBM_TEST_ARTIFACT_DIR opts a failed run into keeping + * its cache for debugging. Forked children use _exit() and cannot run this + * inherited atexit handler. */ + const char *artifact_dir = getenv(TEST_ARTIFACT_DIR_ENV); + if (tf_fail_count != 0 && artifact_dir && artifact_dir[0] != '\0') { + fprintf(stderr, "retained failed test cache: %s\n", test_cache_dir); + return 0; + } + if (getenv(TEST_CACHE_CLEANUP_FAIL_ENV)) { + return -1; + } + if (th_rmtree(test_cache_dir) != 0) { + return -1; + } + test_cache_dir[0] = '\0'; + return 0; +} + +static void cleanup_test_cache_at_exit(void) { + if (cleanup_test_cache() != 0) { + fprintf(stderr, "warning: failed to remove test cache: %s\n", test_cache_dir); + } +} + +static void require_test_cache_cleanup(void) { + if (cleanup_test_cache() != 0) { + fprintf(stderr, "failed to remove test cache: %s\n", test_cache_dir); + tf_fail_count++; + } +} + int main(int argc, char **argv) { /* Skip the multi-hundred-MB executable-image hash that computes the exact * build fingerprint: it is tens of seconds per spawned worker/daemon under @@ -735,6 +875,7 @@ int main(int argc, char **argv) { (void)cbm_setenv("CBM_TEST_BUILD_FINGERPRINT", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 1); } + tf_capture_repository_root(argc > 0 ? argv[0] : NULL); int blocking_git_rc = tf_maybe_run_blocking_git_probe(argc, argv); if (blocking_git_rc >= 0) { return blocking_git_rc; @@ -806,9 +947,16 @@ int main(int argc, char **argv) { const char *skip_perf_env = getenv("CBM_SKIP_PERF"); g_skip_perf = skip_perf_env != NULL && strcmp(skip_perf_env, "1") == 0; + const char *only_suite = getenv("CBM_ONLY_SUITE"); + g_suite_env_filter = only_suite && only_suite[0] ? only_suite : NULL; if (argc == 2 && strcmp(argv[1], "--list-suites") == 0) { g_list_only = true; g_suite_argc = 1; /* no suite-name args to match */ + } else if (g_suite_env_filter) { + /* The environment substring selector and argv exact-name selector are + * alternate interfaces to the same canonical suite registry below. */ + g_suite_argc = 1; + g_suite_argv = argv; } else { g_suite_argc = argc; g_suite_argv = argv; @@ -824,6 +972,55 @@ int main(int argc, char **argv) { printf("\n codebase-memory-mcp C test suite\n"); } + /* DEFAULT-ON store isolation: redirect every test index into a per-run + * temp dir so the suite never pollutes the user's real + * ~/.cache/codebase-memory-mcp. Opt out with CBM_TEST_NO_ISOLATE=1 (e.g. + * to debug against the real store). + * + * Works because every test helper now builds its db path via + * cbm_resolve_cache_dir() (honors CBM_CACHE_DIR), matching the pipeline + * write path. Earlier these helpers hardcoded ~/.cache and mismatched the + * CBM_CACHE_DIR-honoring write → 815 empty-store failures. The production + * path (pipeline.c + mcp.c) honors CBM_CACHE_DIR regardless. */ + const char *no_iso = getenv("CBM_TEST_NO_ISOLATE"); + if (!no_iso || no_iso[0] == '\0') { + const char *artifact_dir = getenv(TEST_ARTIFACT_DIR_ENV); + const char *cache_parent = + artifact_dir && artifact_dir[0] != '\0' ? artifact_dir : cbm_tmpdir(); + if (artifact_dir && artifact_dir[0] != '\0' && !cbm_mkdir_p(artifact_dir, 0755)) { + fprintf(stderr, "failed to create test artifact directory: %s\n", artifact_dir); + return 1; + } + int n = snprintf(test_cache_dir, sizeof(test_cache_dir), "%s/cbm-test-cache-XXXXXX", + cache_parent); + if (n < 0 || (size_t)n >= sizeof(test_cache_dir) || !cbm_mkdtemp(test_cache_dir)) { + fprintf(stderr, "failed to create isolated test cache\n"); + return 1; + } + if (cbm_setenv("CBM_CACHE_DIR", test_cache_dir, ENV_OVERWRITE) != 0 || + atexit(cleanup_test_cache_at_exit) != 0) { + fprintf(stderr, "failed to initialize isolated test cache\n"); + th_cleanup(test_cache_dir); + return 1; + } + } + +/* Every suite from here down MUST go through RUN_SELECTED_SUITE. A bare + * RUN_SUITE would still execute the suite while leaving it out of + * --list-suites, and that failure returns success at every layer: the shard + * union guard in scripts/run-tests-parallel.sh builds both the slices and the + * expected result set from --list-suites, so it would compare the list against + * itself and pass, while the unlisted suite ran inside every other suite's + * invocation — unselectable by argv, counted against whichever suite was + * nominally running, and executed once per shard instead of once per leg. It + * would also run under `make test-tsan`, which passes TEST_TSAN_SUITES as argv + * (Makefile.cbm:997), defeating that list's documented exclusions. + * Poisoning the raw spelling turns that into a compile error naming the fix. + * CBM_ONLY_SUITE and argv selection both flow through this registry. */ +#undef RUN_SUITE +#define RUN_SUITE(name) \ + RUN_SUITE_is_poisoned_in_the_full_run_path__use_RUN_SELECTED_SUITE(name) + /* Foundation */ RUN_SELECTED_SUITE(arena); RUN_SELECTED_SUITE(hash_table); @@ -856,6 +1053,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(store_pragmas); RUN_SELECTED_SUITE(store_checkpoint); RUN_SELECTED_SUITE(dump_verify_io); + RUN_SELECTED_SUITE(schema_declared_property_keys); /* Cypher (M6) */ RUN_SELECTED_SUITE(cypher); @@ -929,6 +1127,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(store_arch); /* HTTP link */ + RUN_SELECTED_SUITE(httplink); /* Traces helpers */ RUN_SELECTED_SUITE(traces); @@ -963,6 +1162,21 @@ int main(int argc, char **argv) { /* UI (config, embedded assets, layout) */ RUN_SELECTED_SUITE(ui); + /* Token reduction */ + RUN_SELECTED_SUITE(token_reduction); + + /* Dependency indexing */ + RUN_SELECTED_SUITE(depindex); + + /* PageRank (node + edge ranking) */ + RUN_SELECTED_SUITE(pagerank); + + /* Tool consolidation (Phase 9) */ + RUN_SELECTED_SUITE(tool_consolidation); + + /* Input validation (fuzz-derived) */ + RUN_SELECTED_SUITE(input_validation); + /* UI HTTP server (transport + routing) */ RUN_SELECTED_SUITE(httpd); @@ -1026,11 +1240,16 @@ int main(int argc, char **argv) { if (g_suite_argc > 1 && !any_suite_matched) { fprintf(stderr, "No matching test suites requested\n"); } + if (g_suite_env_filter && !g_suite_env_matched) { + fprintf(stderr, "Unknown CBM_ONLY_SUITE selector: %s\n", g_suite_env_filter); + tf_fail_count++; + } free(g_suite_arg_matched); g_suite_arg_matched = NULL; /* Release process-lifetime caches so LeakSanitizer reports no leaks. */ cbm_kind_in_set_free_cache(); sqlite3_shutdown(); + require_test_cache_cleanup(); TEST_SUMMARY(); } diff --git a/tests/test_makefile_logged_command.sh b/tests/test_makefile_logged_command.sh new file mode 100644 index 000000000..554a1cdea --- /dev/null +++ b/tests/test_makefile_logged_command.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression guard for Makefile.cbm's portable live-log wrapper. The memory and +# leak gates must report the tested command's failure even though tee is the +# pipeline's final process, and must also fail if tee cannot write the report. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +export LC_ALL=C +POSIX_SHELL="${CBM_TEST_POSIX_SHELL:-/bin/sh}" + +mkdir "$WORKDIR/log-directory" + +cat > "$WORKDIR/probe.mk" <<'MAKEFILE' +include $(ROOT)/Makefile.cbm + +.PHONY: probe-success probe-command-failure probe-tee-failure + +probe-success: + $(call run_logged_command,$(WORKDIR)/success.log,sh -c 'printf "success-output\n"; exit 0') + +probe-command-failure: + $(call run_logged_command,$(WORKDIR)/failure.log,sh -c 'printf "failure-output\n"; exit 7') + +probe-tee-failure: + $(call run_logged_command,$(WORKDIR)/log-directory,sh -c 'printf "tee-failure-output\n"; exit 0') +MAKEFILE + +MAKE=(make -f "$WORKDIR/probe.mk" ROOT="$ROOT" WORKDIR="$WORKDIR" SHELL="$POSIX_SHELL") + +"${MAKE[@]}" probe-success > "$WORKDIR/success.stdout" 2>&1 +grep -qx 'success-output' "$WORKDIR/success.stdout" +grep -qx 'success-output' "$WORKDIR/success.log" + +status=0 +"${MAKE[@]}" probe-command-failure > "$WORKDIR/failure.stdout" 2>&1 || status=$? +if [[ $status -eq 0 ]]; then + echo "FAIL: logged command exit 7 was masked by tee" + exit 1 +fi +grep -q 'Error 7' "$WORKDIR/failure.stdout" +grep -qx 'failure-output' "$WORKDIR/failure.log" + +status=0 +"${MAKE[@]}" probe-tee-failure > "$WORKDIR/tee-failure.stdout" 2>&1 || status=$? +if [[ $status -eq 0 ]]; then + echo "FAIL: tee report-write failure was ignored" + exit 1 +fi +grep -q 'tee-failure-output' "$WORKDIR/tee-failure.stdout" + +status_files="$(find "$WORKDIR" -name '*.status.*' -print)" +if [[ -n "$status_files" ]]; then + echo "FAIL: logged command status sidecar was not removed" + exit 1 +fi + +echo "PASS: Makefile logged commands preserve command and tee failures under $POSIX_SHELL" diff --git a/tests/test_matrix_known_classes.c b/tests/test_matrix_known_classes.c index 8006320b8..c0f83b84d 100644 --- a/tests/test_matrix_known_classes.c +++ b/tests/test_matrix_known_classes.c @@ -73,7 +73,9 @@ static cbm_store_t *mkc_open_indexed(MKC_Proj *lp) { if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); @@ -166,8 +168,7 @@ static int mkc_edge(const MKC_File *files, int nfiles, const char *edge_type, in mkc_diag(store, lp.project, label); } else if (!is_green) { fprintf(stderr, - " [MKC] %s UNEXPECTED PASS %s=%d " - "(bug may be fixed — promote to GREEN)\n", + " [MKC] %s CAPABILITY PRESENT (baseline expected absent) %s=%d\n", label, edge_type, got); } mkc_cleanup(&lp, store); @@ -368,7 +369,7 @@ TEST(mkc_c2_cpp_operator_plus) { /* C2-B: C++ — operator[] subscript overload. * red=bug: `arr[0]` on a custom array type is a subscript_expression; * same root cause as C2-A — no desugaring to CALLS for subscript operators. */ -TEST(mkc_c2_cpp_operator_subscript) { +TEST(mkc_c2_cpp_operator_subscript_does_not_count_data_target) { static const MKC_File f[] = {{"arr.cpp", "struct IntArr {\n int data[8];\n" " int& operator[](int i) { return data[i]; }\n" "};\n\n" @@ -377,10 +378,16 @@ TEST(mkc_c2_cpp_operator_subscript) { "}\n"}}; /* REAL BUG: a[2] should CALLS run->IntArr::operator[]. The subscript * operator desugaring is not modeled — C++ call extraction does not emit a - * call for subscript_expression on an overloaded-operator type → 0 CALLS. - * (Note: C++ binary operator+ desugaring now works — c2/cpp/operator_plus - * passes — but subscript [] is still missing.) [KNOWN class 12] */ - ASSERT_TRUE(mkc_edge(f, 1, "CALLS", 1, "c2/cpp/operator_subscript", 0)); + * call for subscript_expression on an overloaded-operator type. Before + * weak non-callable targets were filtered, this fixture passed by counting + * a false CALLS edge to the data field. Keep the known operator-resolution + * gap explicit without accepting that semantically invalid edge. */ + MKC_Proj lp; + cbm_store_t *store = mkc_index(&lp, f, 1); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_count_edges_by_type(store, lp.project, "CALLS"), 0); + ASSERT_GTE(cbm_store_count_edges_by_type(store, lp.project, "USAGE"), 1); + mkc_cleanup(&lp, store); PASS(); } @@ -1241,9 +1248,9 @@ SUITE(matrix_known_classes) { RUN_TEST(mkc_c1_rust_new_samefile); /* ── CLASS C2: OPERATOR OVERLOADING ────────────────────────────────── */ - /* C2-A/B: C++ operator+/[] — red=bug */ + /* C2-A: operator+ capability; C2-B: operator[] false-target guard. */ RUN_TEST(mkc_c2_cpp_operator_plus); - RUN_TEST(mkc_c2_cpp_operator_subscript); + RUN_TEST(mkc_c2_cpp_operator_subscript_does_not_count_data_target); /* C2-C/D: Python __add__/__getitem__ — red=bug */ RUN_TEST(mkc_c2_python_dunder_add); RUN_TEST(mkc_c2_python_dunder_getitem); diff --git a/tests/test_matrix_new_constructs.c b/tests/test_matrix_new_constructs.c index 116910b73..fb6bb10d1 100644 --- a/tests/test_matrix_new_constructs.c +++ b/tests/test_matrix_new_constructs.c @@ -68,7 +68,9 @@ static cbm_store_t *mn_open_indexed(MN_LangProj *lp) { const char *home = getenv("HOME"); if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 36252b1f9..761276a90 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -6,10 +6,13 @@ #include "../src/foundation/compat.h" #include #include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */ +#include "../src/foundation/compat_thread.h" #include "../src/foundation/constants.h" +#include "../src/foundation/platform.h" +#include +#include "../src/git/git_command.h" #include "../src/foundation/log.h" -#include "../src/foundation/platform.h" /* cbm_file_size */ -#include "../src/foundation/subprocess.h" +#include "../src/foundation/str_util.h" #include "../src/mcp/compact_out.h" #include "test_framework.h" #include "test_helpers.h" @@ -17,6 +20,7 @@ #include /* spawn-count hook — #845 in-process guard */ #include #include +#include #include #include #include @@ -33,17 +37,25 @@ #define cbm_getcwd _getcwd #else #ifdef __APPLE__ -#include +#include /* proc_pidpath — macOS only */ #endif -#include #include -#include +#include +#include /* waitpid — #845 fork+alarm harness */ #include #define cbm_chdir chdir #define cbm_getcwd getcwd extern char **environ; #endif +enum { + MCP_REQUEST_TEST_TIMEOUT_SECONDS = 5, + MCP_TEST_SQLITE_AUTO_LEN = -1, + MCP_TEST_PROJECT_BIND = 1, + MCP_TEST_TOKEN_BIND = 2, + MCP_TEST_VECTOR_BIND = 3, +}; + static bool mcp_response_has_exact_tool(const char *response, const char *expected_name) { yyjson_doc *doc = response ? yyjson_read(response, strlen(response), 0) : NULL; yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; @@ -127,530 +139,340 @@ static void cleanup_project_db(const char *cache, const char *project) { cbm_unlink(path); } -#define MCP_MUTATION_GUARD_MAX_EVENTS 16 +static bool test_file_exists_mcp(const char *path) { + FILE *fp = cbm_fopen(path, "rb"); + if (!fp) { + return false; + } + fclose(fp); + return true; +} -typedef struct { - int deny_begin_call; /* one-based; zero allows every acquisition */ - int deny_try_begin_call; /* one-based; zero allows every try acquisition */ - int cancel_on_begin_call; /* one-based; zero never requests cancellation */ - int begin_count; - int try_begin_count; - int end_count; - cbm_mcp_server_t *cancel_server; - bool cancel_attempted; - bool cancel_accepted; - const char *observed_db_path; - const char *observed_backup_path; - bool db_exists_at_begin; - bool backup_exists_at_begin; - bool db_exists_at_end; - bool backup_exists_at_end; - char begin_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; - char try_begin_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; - char end_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; -} mcp_mutation_guard_probe_t; -typedef struct { - const char *deny_step; - int call_count; - char steps[4][64]; -} mcp_quarantine_hook_probe_t; +TEST(tree_cell_sanitizes_control_and_invalid_utf8) { + /* A raw control or invalid UTF-8 byte makes line-oriented consumers treat + * the complete response as binary. Cell emission therefore stays O(n) + * time/O(1) auxiliary space while escaping controls, replacing malformed + * bytes with U+FFFD, and preserving valid UTF-8 unchanged. */ + cbm_sb_t sb; + cbm_sb_init(&sb); + cbm_tree_cell_str(&sb, + "evil\x01name\xff" + "end", + true); + char *out = cbm_sb_finish(&sb); + ASSERT_NOT_NULL(out); + ASSERT_STR_EQ(out, "\"evil\\u0001name\xEF\xBF\xBD" + "end\""); + free(out); -typedef struct { - bool reject_merge_base; - int diff_calls; - int merge_base_calls; -} mcp_command_hook_probe_t; + cbm_sb_init(&sb); + cbm_tree_cell_str(&sb, "b\xC3\xA4r_ok", true); + out = cbm_sb_finish(&sb); + ASSERT_NOT_NULL(out); + ASSERT_STR_EQ(out, "b\xC3\xA4r_ok"); + free(out); -static bool mcp_quarantine_hook_probe(void *context, const char *step) { - mcp_quarantine_hook_probe_t *probe = context; - if (!probe || !step) { + /* A truncated multibyte lead at the allocation boundary must be replaced + * without reading beyond the terminating NUL. Heap allocation makes an + * out-of-bounds continuation-byte probe visible to ASan. */ + char *truncated = (char *)malloc(2U); + ASSERT_NOT_NULL(truncated); + truncated[0] = (char)0xF0; + truncated[1] = '\0'; + cbm_sb_init(&sb); + cbm_tree_cell_str(&sb, truncated, true); + out = cbm_sb_finish(&sb); + ASSERT_NOT_NULL(out); + ASSERT_STR_EQ(out, "\"\xEF\xBF\xBD\""); + free(out); + free(truncated); + PASS(); +} + +static bool has_stale_freshness_view(const char *json, const char *view_name) { + return json && view_name && strstr(json, "\"freshness\"") && + strstr(json, "\"state\":\"stale_with_warning\"") && + strstr(json, "\"stale_views\"") && strstr(json, view_name); +} + +static bool has_dirty_freshness_counts(const char *response, int pending, int overlay_ready) { + char pending_buf[CBM_SZ_64]; + char overlay_buf[CBM_SZ_64]; + char toon_pending_buf[CBM_SZ_64]; + char toon_overlay_buf[CBM_SZ_64]; + snprintf(pending_buf, sizeof(pending_buf), "\"dirty_files_pending\":%d", pending); + snprintf(overlay_buf, sizeof(overlay_buf), "\"dirty_files_overlay_ready\":%d", + overlay_ready); + snprintf(toon_pending_buf, sizeof(toon_pending_buf), "freshness_dirty_files_pending: %d", + pending); + snprintf(toon_overlay_buf, sizeof(toon_overlay_buf), + "freshness_dirty_files_overlay_ready: %d", overlay_ready); + if (!response) { return false; } - int event = probe->call_count++; - if (event >= 0 && event < 4) { - snprintf(probe->steps[event], sizeof(probe->steps[event]), "%s", step); + bool json_metadata = strstr(response, "\"freshness\"") && + strstr(response, "\"state\":\"dirty_with_warning\"") && + strstr(response, "\"stale_scope\":\"dirty_files\"") && + strstr(response, pending_buf) && strstr(response, overlay_buf); + bool toon_metadata = strstr(response, "freshness_state: dirty_with_warning") && + strstr(response, "freshness_stale_scope: dirty_files") && + strstr(response, toon_pending_buf) && strstr(response, toon_overlay_buf); + return json_metadata || toon_metadata; +} + +/* Freshness facts are one logical contract with JSON and TOON serializers. + * Keep format mapping in these helpers so behavioral tests cannot accidentally + * pin the configured default to one wire representation. Each check is O(N) + * in response bytes with O(1) auxiliary memory. */ +static bool has_freshness_string(const char *response, const char *key, const char *value) { + char json_fragment[CBM_SZ_256]; + char toon_fragment[CBM_SZ_256]; + if (!response || !key || !value) { + return false; } - return !probe->deny_step || strcmp(probe->deny_step, step) != 0; + int json_n = snprintf(json_fragment, sizeof(json_fragment), "\"%s\":\"%s\"", key, value); + int toon_n = snprintf(toon_fragment, sizeof(toon_fragment), "freshness_%s: %s", key, value); + return json_n >= 0 && (size_t)json_n < sizeof(json_fragment) && toon_n >= 0 && + (size_t)toon_n < sizeof(toon_fragment) && + (strstr(response, json_fragment) || strstr(response, toon_fragment)); } -static bool mcp_command_hook_probe(void *context, const char *command) { - mcp_command_hook_probe_t *probe = context; - if (!probe || !command) { +static bool has_freshness_integer(const char *response, const char *key, int value) { + char json_fragment[CBM_SZ_256]; + char toon_fragment[CBM_SZ_256]; + if (!response || !key) { return false; } - if (strstr(command, "merge-base")) { - probe->merge_base_calls++; - return !probe->reject_merge_base; - } - probe->diff_calls++; - return true; + int json_n = snprintf(json_fragment, sizeof(json_fragment), "\"%s\":%d", key, value); + int toon_n = snprintf(toon_fragment, sizeof(toon_fragment), "freshness_%s: %d", key, value); + return json_n >= 0 && (size_t)json_n < sizeof(json_fragment) && toon_n >= 0 && + (size_t)toon_n < sizeof(toon_fragment) && + (strstr(response, json_fragment) || strstr(response, toon_fragment)); } -typedef struct { - const char *name; - char *value; - bool present; -} mcp_test_env_backup_t; - -static void mcp_test_restore_env(mcp_test_env_backup_t *backups, size_t count) { - for (size_t index = 0; index < count; index++) { - if (backups[index].present) { - (void)cbm_setenv(backups[index].name, backups[index].value, 1); - } else { - (void)cbm_unsetenv(backups[index].name); - } - free(backups[index].value); - } +static int mcp_store_node_qn_exists(cbm_store_t *store, const char *project, + const char *qn) { + cbm_node_t node = {0}; + int rc = cbm_store_find_node_by_qn(store, project, qn, &node); + cbm_node_free_fields(&node); + return rc == CBM_STORE_OK ? 1 : 0; } -static int mcp_test_git(const char *root, const char *const *arguments) { - char empty_config[CBM_SZ_4K]; - int config_length = - snprintf(empty_config, sizeof(empty_config), "%s/.cbm-empty-gitconfig", root); - if (config_length <= 0 || (size_t)config_length >= sizeof(empty_config)) { - return -1; - } - FILE *config = cbm_fopen(empty_config, "wb"); - if (!config) { - return -1; - } - if (fclose(config) != 0) { - return -1; - } +static int mcp_store_node_name_count(cbm_store_t *store, const char *project, + const char *name) { + cbm_node_t *nodes = NULL; + int count = 0; + int rc = cbm_store_find_nodes_by_name(store, project, name, &nodes, &count); + int result = rc == CBM_STORE_OK ? count : 0; + cbm_store_free_nodes(nodes, count); + return result; +} - mcp_test_env_backup_t backups[] = { - {.name = "GIT_CONFIG_GLOBAL"}, {.name = "GIT_CONFIG_SYSTEM"}, - {.name = "GIT_CONFIG_NOSYSTEM"}, {.name = "GIT_CONFIG_COUNT"}, - {.name = "GIT_CONFIG_PARAMETERS"}, - }; - bool snapshot_ok = true; - for (size_t index = 0; index < sizeof(backups) / sizeof(backups[0]); index++) { - const char *value = getenv(backups[index].name); - backups[index].present = value != NULL; - backups[index].value = value ? strdup(value) : NULL; - snapshot_ok = snapshot_ok && (!value || backups[index].value); - } - if (!snapshot_ok) { - for (size_t index = 0; index < sizeof(backups) / sizeof(backups[0]); index++) { - free(backups[index].value); - } - return -1; - } - bool environment_ok = cbm_setenv("GIT_CONFIG_GLOBAL", empty_config, 1) == 0 && - cbm_setenv("GIT_CONFIG_SYSTEM", empty_config, 1) == 0 && - cbm_setenv("GIT_CONFIG_NOSYSTEM", "1", 1) == 0 && - cbm_setenv("GIT_CONFIG_COUNT", "0", 1) == 0 && - cbm_unsetenv("GIT_CONFIG_PARAMETERS") == 0; - if (!environment_ok) { - mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); - return -1; +static int mcp_publish_single_node_delta(cbm_store_t *store, const char *project, + int64_t generation, const char *rel_path, + const char *name, const char *qualified_name) { + cbm_node_t node = {.project = project, + .label = "Function", + .name = name, + .qualified_name = qualified_name, + .file_path = rel_path, + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = project, + .rel_path = rel_path, + .generation = generation, + .nodes = &node, + .node_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + return cbm_store_publish_file_delta(store, &delta); +} + +static int mcp_publish_delete_overlay_delta(cbm_store_t *store, const char *project, + int64_t base_generation, + int64_t overlay_generation, + const char *rel_path) { + cbm_store_file_delta_t delta = {.project = project, + .rel_path = rel_path, + .generation = base_generation, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + return cbm_store_publish_overlay_file_delta(store, &delta, overlay_generation); +} + +static int mcp_project_db_path(char *out, size_t out_sz, const char *cache, + const char *project) { + if (!out || out_sz == 0 || !cache || !project) { + return CBM_STORE_ERR; + } + int n = snprintf(out, out_sz, "%s/%s.db", cache, project); + if (n < 0 || (size_t)n >= out_sz) { + out[0] = '\0'; + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +static bool mcp_create_generation_db(const char *db_path, const char *project, + const char *node_label, const char *node_name) { + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return false; } + char qualified_name[CBM_PATH_MAX]; + int n = snprintf(qualified_name, sizeof(qualified_name), "%s.%s", project, node_name); + cbm_node_t node = {.project = project, + .label = node_label, + .name = node_name, + .qualified_name = qualified_name, + .file_path = "src/generation.c", + .start_line = 1, + .end_line = 2, + .properties_json = "{}"}; + bool ok = n >= 0 && (size_t)n < sizeof(qualified_name) && + cbm_store_upsert_project(store, project, "/synthetic/repository") == CBM_STORE_OK && + cbm_store_upsert_node(store, &node) > 0; + cbm_store_close(store); + return ok; +} - const char *git = "git"; -#ifdef _WIN32 - char git_executable[CBM_SZ_4K]; - const char *resolved = cbm_find_cli("git", cbm_get_home_dir()); - int resolved_length = - resolved ? snprintf(git_executable, sizeof(git_executable), "%s", resolved) : -1; - if (resolved_length <= 0 || (size_t)resolved_length >= sizeof(git_executable)) { - mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); - return -1; +static void mcp_unlink_db_sidecars(const char *db_path) { + if (!db_path || !db_path[0]) { + return; } - git = git_executable; -#endif - const char *argv[24] = {git, "-C", root}; - size_t index = 3; - while (arguments && *arguments && index + 1 < sizeof(argv) / sizeof(argv[0])) { - argv[index++] = *arguments++; + cbm_unlink(db_path); + char sidecar[CBM_PATH_MAX]; + int n = snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path); + if (n >= 0 && (size_t)n < sizeof(sidecar)) { + cbm_unlink(sidecar); } - if ((arguments && *arguments) || index >= sizeof(argv) / sizeof(argv[0])) { - mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); - return -1; + n = snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path); + if (n >= 0 && (size_t)n < sizeof(sidecar)) { + cbm_unlink(sidecar); } - argv[index] = NULL; - - cbm_proc_opts_t options = { - .bin = git, - .argv = argv, - .quiet_timeout_ms = 10000, - }; - cbm_proc_result_t result = {0}; - int run_result = - cbm_subprocess_run(&options, &result) == 0 && result.outcome == CBM_PROC_CLEAN ? 0 : -1; - mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0])); - return run_result; } -static bool mcp_mutation_guard_probe_begin(void *context, const char *project) { - mcp_mutation_guard_probe_t *probe = context; - if (!probe) { - return false; +static void mcp_restore_cache_dir(char *saved_copy) { + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); } +} - int event = probe->begin_count++; - if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { - snprintf(probe->begin_projects[event], sizeof(probe->begin_projects[event]), "%s", - project ? project : ""); +static int mcp_create_overlay_compaction_fixture(const char *cache, const char *project, + char *db_path, size_t db_path_sz) { + int rc = mcp_project_db_path(db_path, db_path_sz, cache, project); + if (rc != CBM_STORE_OK) { + return rc; } - if (probe->cancel_on_begin_call > 0 && probe->begin_count == probe->cancel_on_begin_call) { - probe->cancel_attempted = true; - probe->cancel_accepted = cbm_mcp_server_cancel_active(probe->cancel_server); + + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return CBM_STORE_ERR; } - if (probe->observed_db_path) { - probe->db_exists_at_begin = cbm_file_exists(probe->observed_db_path); + + int64_t generation = 0; + rc = cbm_store_upsert_project(store, project, cache); + if (rc == CBM_STORE_OK) { + rc = cbm_store_reserve_index_generation(store, project, NULL, NULL, &generation); } - if (probe->observed_backup_path) { - probe->backup_exists_at_begin = cbm_file_exists(probe->observed_backup_path); + char main_qn[CBM_PATH_MAX]; + char helper_qn[CBM_PATH_MAX]; + int n = snprintf(main_qn, sizeof(main_qn), "%s.main.Old", project); + if (rc == CBM_STORE_OK && (n < 0 || (size_t)n >= sizeof(main_qn))) { + rc = CBM_STORE_ERR; } - return probe->deny_begin_call == 0 || probe->begin_count != probe->deny_begin_call; -} - -static bool mcp_mutation_guard_probe_try_begin(void *context, const char *project) { - mcp_mutation_guard_probe_t *probe = context; - if (!probe) { - return false; + n = snprintf(helper_qn, sizeof(helper_qn), "%s.helper.Helper", project); + if (rc == CBM_STORE_OK && (n < 0 || (size_t)n >= sizeof(helper_qn))) { + rc = CBM_STORE_ERR; } - - int event = probe->try_begin_count++; - if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { - snprintf(probe->try_begin_projects[event], sizeof(probe->try_begin_projects[event]), "%s", - project ? project : ""); + if (rc == CBM_STORE_OK) { + rc = mcp_publish_single_node_delta(store, project, generation, "main.go", "Old", + main_qn); } - if (probe->observed_db_path) { - probe->db_exists_at_begin = cbm_file_exists(probe->observed_db_path); + if (rc == CBM_STORE_OK) { + rc = mcp_publish_single_node_delta(store, project, generation, "helper.go", "Helper", + helper_qn); } - if (probe->observed_backup_path) { - probe->backup_exists_at_begin = cbm_file_exists(probe->observed_backup_path); + if (rc == CBM_STORE_OK) { + rc = cbm_store_finish_index_generation(store, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE); } - return probe->deny_try_begin_call == 0 || probe->try_begin_count != probe->deny_try_begin_call; -} -static void mcp_mutation_guard_probe_end(void *context, const char *project) { - mcp_mutation_guard_probe_t *probe = context; - if (!probe) { - return; + int64_t first_overlay = 0; + if (rc == CBM_STORE_OK) { + rc = cbm_store_reserve_overlay_generation(store, project, generation, &first_overlay); } - - int event = probe->end_count++; - if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { - snprintf(probe->end_projects[event], sizeof(probe->end_projects[event]), "%s", - project ? project : ""); + if (rc == CBM_STORE_OK) { + rc = mcp_publish_delete_overlay_delta(store, project, generation, first_overlay, + "main.go"); } - if (probe->observed_db_path) { - probe->db_exists_at_end = cbm_file_exists(probe->observed_db_path); + + int64_t second_overlay = 0; + if (rc == CBM_STORE_OK) { + rc = cbm_store_reserve_overlay_generation(store, project, generation, &second_overlay); } - if (probe->observed_backup_path) { - probe->backup_exists_at_end = cbm_file_exists(probe->observed_backup_path); + if (rc == CBM_STORE_OK) { + rc = mcp_publish_delete_overlay_delta(store, project, generation, second_overlay, + "helper.go"); } + + cbm_store_close(store); + return rc; } -static bool mcp_make_corrupt_project_store(const char *cache, const char *project) { - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - cbm_store_t *store = cbm_store_open_path(db_path); - if (!store) { - return false; - } +/* ══════════════════════════════════════════════════════════════════ + * JSON-RPC PARSING + * ══════════════════════════════════════════════════════════════════ */ - /* Numeric root paths are the deterministic corruption trigger used by - * cbm_store_check_integrity() and the issue #557 reproduction. */ - bool created = cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK; - cbm_store_close(store); - return created; +TEST(jsonrpc_parse_request) { + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"capabilities\":{}}}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_STR_EQ(req.jsonrpc, "2.0"); + ASSERT_STR_EQ(req.method, "initialize"); + ASSERT_EQ(req.id, 1); + ASSERT_TRUE(req.has_id); + ASSERT_NOT_NULL(req.params_raw); + cbm_jsonrpc_request_free(&req); + PASS(); } -/* Keep a writer open so the fixture has a real, committed WAL generation. - * Query-only opens must not alter either file when quarantine is denied or - * cannot be published safely. The caller owns the returned store. */ -static cbm_store_t *mcp_open_corrupt_project_store_with_wal(const char *cache, - const char *project) { - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - cbm_store_t *store = cbm_store_open_path(db_path); - if (!store) { - return NULL; - } +TEST(jsonrpc_parse_notification) { + const char *line = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_STR_EQ(req.method, "notifications/initialized"); + ASSERT_FALSE(req.has_id); + cbm_jsonrpc_request_free(&req); + PASS(); +} - bool ready = - cbm_store_exec(store, "PRAGMA wal_autocheckpoint=0;") == CBM_STORE_OK && - cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK && - cbm_store_exec(store, "CREATE TABLE IF NOT EXISTS guard_wal_sentinel(value TEXT);" - "INSERT INTO guard_wal_sentinel(value) VALUES('committed');") == - CBM_STORE_OK; - if (!ready) { - cbm_store_close(store); - return NULL; - } - return store; +TEST(jsonrpc_parse_invalid) { + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse("not json", &req); + ASSERT_EQ(rc, CBM_JSONRPC_PARSE_ERROR); + cbm_jsonrpc_request_free(&req); + PASS(); } -static bool mcp_make_valid_project_store_at(const char *path, const char *project, - const char *root_path) { - cbm_store_t *store = cbm_store_open_path(path); - if (!store) { - return false; - } - bool ready = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK && - cbm_store_prepare_for_publish(store) == CBM_STORE_OK; - cbm_store_close(store); - return ready; -} - -static unsigned char *mcp_read_file_bytes(const char *path, long *out_len) { - if (!out_len) { - return NULL; - } - *out_len = 0; - FILE *fp = cbm_fopen(path, "rb"); - if (!fp) { - return NULL; - } - if (fseek(fp, 0, SEEK_END) != 0) { - fclose(fp); - return NULL; - } - long size = ftell(fp); - if (size < 0 || fseek(fp, 0, SEEK_SET) != 0) { - fclose(fp); - return NULL; - } - unsigned char *bytes = malloc(size > 0 ? (size_t)size : 1); - if (!bytes) { - fclose(fp); - return NULL; - } - size_t read_count = fread(bytes, 1, (size_t)size, fp); - fclose(fp); - if (read_count != (size_t)size) { - free(bytes); - return NULL; - } - *out_len = size; - return bytes; -} - -static bool mcp_file_matches_snapshot(const char *path, const unsigned char *expected, - long expected_len) { - long actual_len = 0; - unsigned char *actual = mcp_read_file_bytes(path, &actual_len); - bool matches = actual && expected && actual_len == expected_len && - memcmp(actual, expected, (size_t)actual_len) == 0; - free(actual); - return matches; -} - -/* Return the number of quarantine files for a project and, when present, the - * first path whose name is distinct from the legacy fixed `.corrupt` name. */ -static bool mcp_is_corrupt_backup_main_name(const char *name, const char *prefix) { - size_t prefix_len = strlen(prefix); - if (strcmp(name, prefix) == 0) { - return true; - } - const char *suffix = name + prefix_len; - if (strncmp(name, prefix, prefix_len) != 0 || suffix[0] != '.' || strlen(suffix + 1) != 16) { - return false; - } - for (const char *cursor = suffix + 1; *cursor; cursor++) { - if (!isxdigit((unsigned char)*cursor)) { - return false; - } - } - return true; -} - -static int mcp_find_corrupt_backups(const char *cache, const char *project, char *unique_path, - size_t unique_path_size) { - if (unique_path && unique_path_size > 0) { - unique_path[0] = '\0'; - } - char prefix[CBM_DIRENT_NAME_MAX]; - snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); - int count = 0; - cbm_dir_t *dir = cbm_opendir(cache); - if (!dir) { - return 0; - } - cbm_dirent_t *entry; - while ((entry = cbm_readdir(dir)) != NULL) { - if (!mcp_is_corrupt_backup_main_name(entry->name, prefix)) { - continue; - } - char path[CBM_SZ_1K]; - snprintf(path, sizeof(path), "%s/%s", cache, entry->name); - if (!cbm_file_exists(path)) { - continue; - } - count++; - if (unique_path && unique_path_size > 0 && unique_path[0] == '\0' && - strcmp(entry->name, prefix) != 0) { - snprintf(unique_path, unique_path_size, "%s", path); - } - } - cbm_closedir(dir); - return count; -} - -static int mcp_count_corrupt_artifacts(const char *cache, const char *project) { - char prefix[CBM_DIRENT_NAME_MAX]; - snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); - size_t prefix_len = strlen(prefix); - int count = 0; - cbm_dir_t *dir = cbm_opendir(cache); - if (!dir) { - return 0; - } - cbm_dirent_t *entry; - while ((entry = cbm_readdir(dir)) != NULL) { - if (strncmp(entry->name, prefix, prefix_len) == 0) { - count++; - } - } - cbm_closedir(dir); - return count; -} - -static int mcp_count_directory_entries_with_prefix(const char *directory, const char *prefix) { - cbm_dir_t *dir = cbm_opendir(directory); - if (!dir) { - return -1; - } - size_t prefix_length = strlen(prefix); - int count = 0; - cbm_dirent_t *entry; - while ((entry = cbm_readdir(dir)) != NULL) { - if (strncmp(entry->name, prefix, prefix_length) == 0) { - count++; - } - } - cbm_closedir(dir); - return count; -} - -static void mcp_cleanup_corrupt_backups(const char *cache, const char *project) { - char prefix[CBM_DIRENT_NAME_MAX]; - snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); - size_t prefix_len = strlen(prefix); - cbm_dir_t *dir = cbm_opendir(cache); - if (!dir) { - return; - } - cbm_dirent_t *entry; - while ((entry = cbm_readdir(dir)) != NULL) { - if (strncmp(entry->name, prefix, prefix_len) == 0) { - char path[CBM_SZ_1K]; - snprintf(path, sizeof(path), "%s/%s", cache, entry->name); - cbm_unlink(path); - } - } - cbm_closedir(dir); -} - -typedef struct { - mcp_mutation_guard_probe_t guard; - const char *replacement_path; - const char *live_path; - bool replacement_attempted; - bool replacement_succeeded; -} mcp_replacing_mutation_guard_t; - -static bool mcp_replacing_mutation_guard_begin(void *context, const char *project) { - mcp_replacing_mutation_guard_t *replacement = context; - if (!replacement || !mcp_mutation_guard_probe_begin(&replacement->guard, project)) { - return false; - } - replacement->replacement_attempted = true; - bool sidecars_removed = cbm_remove_db_sidecars(replacement->live_path) == 0; - replacement->replacement_succeeded = - sidecars_removed && - cbm_rename_replace(replacement->replacement_path, replacement->live_path) == 0; - return true; -} - -static void mcp_replacing_mutation_guard_end(void *context, const char *project) { - mcp_replacing_mutation_guard_t *replacement = context; - if (replacement) { - mcp_mutation_guard_probe_end(&replacement->guard, project); - } -} - -TEST(tree_cell_sanitizes_control_and_invalid_utf8) { - /* One raw control or invalid-UTF8 byte in a cell poisons LINE-ORIENTED - * consumers of the ENTIRE output (BSD grep treats all of it as - * unmatchable binary — the macos-15-intel release-smoke B3 class), so - * cell emission guarantees valid UTF-8: control bytes escape as \u00XX, - * invalid sequences become U+FFFD, and both force the quoted form. */ - cbm_sb_t sb; - cbm_sb_init(&sb); - cbm_tree_cell_str(&sb, - "evil\x01name\xff" - "end", - true); - char *out = cbm_sb_finish(&sb); - ASSERT_NOT_NULL(out); - ASSERT_STR_EQ(out, "\"evil\\u0001name\xEF\xBF\xBD" - "end\""); - free(out); - - cbm_sb_init(&sb); - cbm_tree_cell_str(&sb, "b\xC3\xA4r_ok", true); - out = cbm_sb_finish(&sb); - ASSERT_NOT_NULL(out); - ASSERT_STR_EQ(out, "b\xC3\xA4r_ok"); /* valid UTF-8 stays raw + unquoted */ - free(out); - PASS(); -} - -/* ══════════════════════════════════════════════════════════════════ - * JSON-RPC PARSING - * ══════════════════════════════════════════════════════════════════ */ - -TEST(jsonrpc_parse_request) { - const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," - "\"params\":{\"capabilities\":{}}}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_STR_EQ(req.jsonrpc, "2.0"); - ASSERT_STR_EQ(req.method, "initialize"); - ASSERT_EQ(req.id, 1); - ASSERT_TRUE(req.has_id); - ASSERT_NOT_NULL(req.params_raw); - cbm_jsonrpc_request_free(&req); - PASS(); -} - -TEST(jsonrpc_parse_notification) { - const char *line = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_STR_EQ(req.method, "notifications/initialized"); - ASSERT_FALSE(req.has_id); - cbm_jsonrpc_request_free(&req); - PASS(); -} - -TEST(jsonrpc_parse_invalid) { - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse("not json", &req); - ASSERT_EQ(rc, -1); - cbm_jsonrpc_request_free(&req); - PASS(); -} - -TEST(jsonrpc_parse_tools_call) { - const char *line = "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"label\":\"Function\",\"limit\":5}}}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_STR_EQ(req.method, "tools/call"); - ASSERT_EQ(req.id, 42); - ASSERT_NOT_NULL(req.params_raw); - cbm_jsonrpc_request_free(&req); - PASS(); +TEST(jsonrpc_parse_tools_call) { + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"label\":\"Function\",\"limit\":5}}}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_STR_EQ(req.method, "tools/call"); + ASSERT_EQ(req.id, 42); + ASSERT_NOT_NULL(req.params_raw); + cbm_jsonrpc_request_free(&req); + PASS(); } /* issue #253: JSON-RPC 2.0 §4 permits string ids (Claude Desktop sends them @@ -734,12 +556,15 @@ TEST(mcp_initialize_response) { ASSERT_NOT_NULL(strstr(json, "\"version\":\"9.8.7-test\"")); ASSERT_NOT_NULL(strstr(json, "capabilities")); ASSERT_NOT_NULL(strstr(json, "tools")); - ASSERT_NOT_NULL(strstr(json, "\"listChanged\":false")); - ASSERT_NOT_NULL(strstr(json, "\"prompts\":{\"listChanged\":false}")); - ASSERT_NOT_NULL(strstr(json, "\"instructions\":")); - ASSERT_NOT_NULL(strstr(json, "search_graph")); - ASSERT_NOT_NULL(strstr(json, "auto-refresh")); + ASSERT_NOT_NULL(strstr(json, "\"listChanged\":true")); ASSERT_NOT_NULL(strstr(json, "2025-11-25")); + /* The default tool mode is streamlined, where get_code is visible and + * get_code_snippet is hidden until _hidden_tools reveals it. Initialization + * must not direct a client to a tool it cannot call yet. */ + ASSERT_NOT_NULL(strstr(json, "get_code for exact source")); + ASSERT_NULL(strstr(json, "get_code_snippet for exact source")); + ASSERT_NOT_NULL(strstr(json, "first graph or source call automatically resolves")); + ASSERT_NOT_NULL(strstr(json, "follow action_required when automation cannot complete")); free(json); /* Client requests a supported version: server echoes it */ @@ -762,32 +587,82 @@ TEST(mcp_initialize_response) { PASS(); } -TEST(mcp_tools_list) { - char *json = cbm_mcp_tools_list(); +TEST(mcp_initialize_resources_do_not_claim_static_list_changes) { + char *json = cbm_mcp_initialize_response(NULL); ASSERT_NOT_NULL(json); - /* Should contain all tools, including the targeted coverage gate. */ - ASSERT_NOT_NULL(strstr(json, "index_repository")); - ASSERT_NOT_NULL(strstr(json, "search_graph")); - ASSERT_NOT_NULL(strstr(json, "query_graph")); - ASSERT_NOT_NULL(strstr(json, "trace_path")); - ASSERT_NOT_NULL(strstr(json, "get_code_snippet")); - ASSERT_NOT_NULL(strstr(json, "get_graph_schema")); - ASSERT_NOT_NULL(strstr(json, "get_architecture")); - ASSERT_NOT_NULL(strstr(json, "search_code")); - ASSERT_NOT_NULL(strstr(json, "list_projects")); - ASSERT_NOT_NULL(strstr(json, "delete_project")); - ASSERT_NOT_NULL(strstr(json, "index_status")); - ASSERT_NOT_NULL(strstr(json, "check_index_coverage")); - ASSERT_NOT_NULL(strstr(json, "detect_changes")); - ASSERT_NOT_NULL(strstr(json, "manage_adr")); - ASSERT_NOT_NULL(strstr(json, "ingest_traces")); - free(json); - PASS(); -} -/* #1361: --help omitted check_index_coverage because its tool list was a - * hand-maintained copy. The list is now rendered from the registry; this pins - * the render so a formatter bug cannot reintroduce a silent omission. */ + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *capabilities = yyjson_obj_get(root, "capabilities"); + yyjson_val *tools = yyjson_obj_get(capabilities, "tools"); + yyjson_val *resources = yyjson_obj_get(capabilities, "resources"); + ASSERT_NOT_NULL(tools); + ASSERT_NOT_NULL(resources); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(tools, "listChanged"))); + ASSERT_NULL(yyjson_obj_get(resources, "listChanged")); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(resources, "subscribe"))); + + yyjson_doc_free(doc); + free(json); + PASS(); +} + +TEST(mcp_tools_list) { + char *json = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(json); + /* §4b: when srv=NULL (no config), cbm_mcp_tools_list defaults to "streamlined" + * mode and emits five user-facing tools plus _hidden_tools. Canonical + * tools (including trace_path) come from TOOLS[] so classic and + * streamlined schemas cannot drift; get_code is the concise alias from + * STREAMLINED_TOOLS[]. The old search_code_graph mega-tool has been + * deleted. */ + ASSERT_NOT_NULL(strstr(json, "search_graph")); + ASSERT_NOT_NULL(strstr(json, "query_graph")); + ASSERT_NOT_NULL(strstr(json, "search_code")); + ASSERT_NOT_NULL(strstr(json, "trace_path")); + ASSERT_NOT_NULL(strstr(json, "get_code")); + ASSERT_NOT_NULL( + strstr(json, "search_code resolves its project through the same auto-indexing path")); + ASSERT_NULL(strstr(json, "search_code searches source files for an already indexed/current")); + /* The deleted mega-tool must NOT appear */ + ASSERT_NULL(strstr(json, "search_code_graph")); + /* Hidden classic tools should NOT appear as top-level tool entries */ + ASSERT_NULL(strstr(json, "\"index_repository\"")); + free(json); + PASS(); +} + +static char *mcp_tools_list_classic_snapshot(void) { + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + char *json = cbm_mcp_tools_list(NULL); + cbm_unsetenv("CBM_TOOL_MODE"); + return json; +} + +TEST(mcp_tools_list_classic_mode) { + /* Classic mode (CBM_TOOL_MODE=classic) emits the 16 canonical tools, + * not the streamlined consolidated set. The env var is read at call time, + * so set it, capture the list, then unset it BEFORE any ASSERT — a failed + * assert must not leak the classic setting into sibling tests (which expect + * the streamlined default). */ + char *json = mcp_tools_list_classic_snapshot(); + ASSERT_NOT_NULL(json); + /* Classic split tools are present (TOOLS[] in mcp.c). */ + ASSERT_NOT_NULL(strstr(json, "\"index_repository\"")); + ASSERT_NOT_NULL(strstr(json, "\"search_graph\"")); + ASSERT_NOT_NULL(strstr(json, "\"query_graph\"")); + /* The streamlined-only consolidated tool + progressive-disclosure hint are + * NOT emitted in classic mode. */ + ASSERT_NULL(strstr(json, "\"search_code_graph\"")); + ASSERT_NULL(strstr(json, "_hidden_tools")); + free(json); + PASS(); +} + +/* #1361: --help omitted check_index_coverage because its tool list was a + * hand-maintained copy. The list is now rendered from the registry; this pins + * the render so a formatter bug cannot reintroduce a silent omission. */ TEST(mcp_tools_help_list_matches_registry) { char *help = cbm_mcp_tools_help_list(); ASSERT_NOT_NULL(help); @@ -820,7 +695,7 @@ TEST(mcp_tools_help_list_matches_registry) { } TEST(mcp_tools_list_latest_metadata) { - char *json = cbm_mcp_tools_list(); + char *json = mcp_tools_list_classic_snapshot(); ASSERT_NOT_NULL(json); ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\"")); ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\"")); @@ -831,6 +706,76 @@ TEST(mcp_tools_list_latest_metadata) { PASS(); } +TEST(mcp_tool_input_schemas_are_closed_in_classic_and_streamlined_modes) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *revealed = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(revealed); + ASSERT_NULL(strstr(revealed, "\"isError\":true")); + free(revealed); + + char *snapshots[] = {mcp_tools_list_classic_snapshot(), cbm_mcp_tools_list(NULL), + cbm_mcp_tools_list(srv)}; + for (size_t si = 0; si < sizeof(snapshots) / sizeof(snapshots[0]); si++) { + ASSERT_NOT_NULL(snapshots[si]); + yyjson_doc *doc = yyjson_read(snapshots[si], strlen(snapshots[si]), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *tools = yyjson_obj_get(yyjson_doc_get_root(doc), "tools"); + ASSERT_TRUE(yyjson_is_arr(tools)); + yyjson_arr_iter iter; + yyjson_arr_iter_init(tools, &iter); + yyjson_val *tool; + while ((tool = yyjson_arr_iter_next(&iter)) != NULL) { + yyjson_val *schema = yyjson_obj_get(tool, "inputSchema"); + if (!yyjson_is_obj(schema)) { + yyjson_val *name = yyjson_obj_get(tool, "name"); + FAIL(yyjson_is_str(name) ? yyjson_get_str(name) : "tool missing name and schema"); + } + yyjson_val *closed = yyjson_obj_get(schema, "additionalProperties"); + ASSERT_TRUE(yyjson_is_bool(closed)); + ASSERT_FALSE(yyjson_get_bool(closed)); + } + yyjson_doc_free(doc); + free(snapshots[si]); + } + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(mcp_canonical_input_schemas_cover_implemented_format_and_verbose_options) { + struct { + const char *tool; + const char *property; + } cases[] = {{"index_repository", "format"}, {"index_status", "verbose"}}; + + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const char *schema_json = cbm_mcp_tool_input_schema(cases[i].tool); + ASSERT_NOT_NULL(schema_json); + yyjson_doc *doc = yyjson_read(schema_json, strlen(schema_json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *properties = yyjson_obj_get(yyjson_doc_get_root(doc), "properties"); + ASSERT_TRUE(yyjson_is_obj(properties)); + ASSERT_NOT_NULL(yyjson_obj_get(properties, cases[i].property)); + yyjson_doc_free(doc); + } + PASS(); +} + +TEST(mcp_index_repository_auto_dep_limit_schema_uses_shared_bounds) { + const char *schema_json = cbm_mcp_tool_input_schema("index_repository"); + ASSERT_NOT_NULL(schema_json); + yyjson_doc *doc = yyjson_read(schema_json, strlen(schema_json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *properties = yyjson_obj_get(yyjson_doc_get_root(doc), "properties"); + ASSERT_TRUE(yyjson_is_obj(properties)); + yyjson_val *limit = yyjson_obj_get(properties, CBM_CONFIG_AUTO_DEP_LIMIT); + ASSERT_TRUE(yyjson_is_obj(limit)); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(limit, "minimum")), 0); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(limit, "maximum")), CBM_MAX_AUTO_DEP_LIMIT); + yyjson_doc_free(doc); + PASS(); +} + TEST(mcp_tools_have_behavior_annotations) { struct { const char *name; @@ -840,26 +785,24 @@ TEST(mcp_tools_have_behavior_annotations) { bool open_world; } expected[] = { {"index_repository", false, false, true, false}, - /* These query tools can reach resolve_store(), whose corrupt-store - * recovery quarantines/removes database files. Keep the annotations - * conservative until query resolution is strictly non-mutating. */ - {"search_graph", false, true, true, false}, - {"query_graph", false, true, true, false}, - {"trace_path", false, true, true, false}, - {"get_code_snippet", false, true, true, false}, - {"get_graph_schema", false, true, true, false}, - {"get_architecture", false, true, true, false}, - {"search_code", false, true, true, false}, + {"search_graph", true, false, true, false}, + {"query_graph", true, false, true, false}, + {"trace_path", true, false, true, false}, + {"get_code_snippet", true, false, true, false}, + {"get_graph_schema", true, false, true, false}, + {"get_architecture", true, false, true, false}, + {"search_code", true, false, true, false}, {"list_projects", true, false, true, false}, {"delete_project", false, true, true, false}, - {"index_status", false, true, true, false}, - {"check_index_coverage", false, true, true, false}, - {"detect_changes", false, true, true, false}, - {"manage_adr", false, true, false, false}, + {"index_status", true, false, true, false}, + {"check_index_coverage", true, false, true, false}, + {"detect_changes", true, false, true, false}, + {"manage_adr", false, false, false, false}, {"ingest_traces", false, false, false, false}, + {"index_dependencies", false, false, true, false}, }; - char *json = cbm_mcp_tools_list(); + char *json = mcp_tools_list_classic_snapshot(); ASSERT_NOT_NULL(json); yyjson_doc *doc = yyjson_read(json, strlen(json), 0); ASSERT_NOT_NULL(doc); @@ -910,7 +853,7 @@ TEST(mcp_tools_have_behavior_annotations) { } TEST(mcp_index_repository_declares_name_override_issue571) { - char *json = cbm_mcp_tools_list(); + char *json = mcp_tools_list_classic_snapshot(); ASSERT_NOT_NULL(json); ASSERT_NOT_NULL(strstr(json, "\"index_repository\"")); ASSERT_NOT_NULL(strstr(json, "\"name\":{\"type\":\"string\"")); @@ -924,7 +867,7 @@ TEST(mcp_tools_array_schemas_have_items) { * https://github.com/microsoft/vscode/issues/248810). * Walk every tool's inputSchema and verify that every "type":"array" * property also contains "items". */ - char *json = cbm_mcp_tools_list(); + char *json = mcp_tools_list_classic_snapshot(); ASSERT_NOT_NULL(json); /* Scan for all occurrences of "type":"array" — each must be followed @@ -949,7 +892,7 @@ TEST(mcp_tools_array_schemas_have_items) { } TEST(mcp_ingest_traces_items_disallow_additional_properties_issue731) { - char *json = cbm_mcp_tools_list(); + char *json = mcp_tools_list_classic_snapshot(); ASSERT_NOT_NULL(json); yyjson_doc *doc = yyjson_read(json, strlen(json), 0); @@ -1009,7 +952,7 @@ TEST(mcp_ingest_traces_items_disallow_additional_properties_issue731) { * mirroring VALID_ASPECTS in mcp.c. Parsed structurally like * mcp_ingest_traces_items_disallow_additional_properties_issue731. */ TEST(mcp_get_architecture_aspects_schema_enum_pr560) { - char *json = cbm_mcp_tools_list(); + char *json = mcp_tools_list_classic_snapshot(); ASSERT_NOT_NULL(json); yyjson_doc *doc = yyjson_read(json, strlen(json), 0); @@ -1115,6 +1058,24 @@ TEST(mcp_text_result_error) { PASS(); } +TEST(supervised_index_response_publication_status_contract) { + char *indexed = cbm_mcp_text_result("{\"status\":\"indexed\"}", false); + char *degraded = cbm_mcp_text_result("{\"status\":\"degraded\"}", false); + char *failed = cbm_mcp_text_result("{\"status\":\"error\"}", true); + ASSERT_NOT_NULL(indexed); + ASSERT_NOT_NULL(degraded); + ASSERT_NOT_NULL(failed); + ASSERT_TRUE(cbm_mcp_index_response_published(indexed)); + ASSERT_TRUE(cbm_mcp_index_response_published(degraded)); + ASSERT_FALSE(cbm_mcp_index_response_published(failed)); + ASSERT_FALSE(cbm_mcp_index_response_published("not-json")); + ASSERT_FALSE(cbm_mcp_index_response_published(NULL)); + free(indexed); + free(degraded); + free(failed); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * ARGUMENT EXTRACTION * ══════════════════════════════════════════════════════════════════ */ @@ -1164,6 +1125,10 @@ TEST(mcp_get_int_arg) { ASSERT_EQ(val, 5); val = cbm_mcp_get_int_arg(args, "missing", 42); ASSERT_EQ(val, 42); + val = cbm_mcp_get_int_arg("{\"limit\":4294967297}", "limit", 17); + ASSERT_EQ(val, 17); + val = cbm_mcp_get_int_arg("{\"limit\":-9223372036854775808}", "limit", 19); + ASSERT_EQ(val, 19); PASS(); } @@ -1193,12 +1158,34 @@ TEST(server_handle_initialize) { ASSERT_NOT_NULL(strstr(resp, "\"id\":1")); ASSERT_NOT_NULL(strstr(resp, "codebase-memory-mcp")); ASSERT_NOT_NULL(strstr(resp, "capabilities")); + ASSERT_NOT_NULL(strstr(resp, "get_code for exact source")); + ASSERT_NULL(strstr(resp, "get_code_snippet for exact source")); + ASSERT_NOT_NULL(strstr(resp, "first graph or source call automatically resolves")); + ASSERT_NOT_NULL(strstr(resp, "follow action_required when automation cannot complete")); free(resp); cbm_mcp_server_free(srv); PASS(); } +TEST(server_handle_initialize_names_classic_source_tool) { + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"capabilities\":{}}}"); + cbm_mcp_server_free(srv); + cbm_unsetenv("CBM_TOOL_MODE"); + + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "get_code_snippet for exact source")); + ASSERT_NULL(strstr(resp, "get_code for exact source")); + ASSERT_NOT_NULL(strstr(resp, "first graph or source call automatically resolves")); + ASSERT_NOT_NULL(strstr(resp, "follow action_required when automation cannot complete")); + free(resp); + PASS(); +} + TEST(server_handle_initialized_notification) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -1218,8 +1205,9 @@ TEST(server_handle_tools_list) { cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "\"id\":2")); + /* §4b: streamlined mode default surface — 5 split tools */ ASSERT_NOT_NULL(strstr(resp, "search_graph")); - ASSERT_NOT_NULL(strstr(resp, "query_graph")); + ASSERT_NOT_NULL(strstr(resp, "trace_path")); free(resp); cbm_mcp_server_free(srv); @@ -1227,35 +1215,39 @@ TEST(server_handle_tools_list) { } TEST(server_handle_tools_list_defaults_to_all_tools_and_accepts_cursor) { + cbm_setenv("CBM_TOOL_MODE", "classic", 1); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char *resp = + char *full_resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":200,\"method\":\"tools/list\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"id\":200")); - ASSERT_NULL(strstr(resp, "\"nextCursor\"")); - ASSERT_NOT_NULL(strstr(resp, "index_repository")); - ASSERT_NOT_NULL(strstr(resp, "manage_adr")); - ASSERT_NOT_NULL(strstr(resp, "ingest_traces")); - free(resp); - - resp = cbm_mcp_server_handle( + char *empty_params_resp = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":202,\"method\":\"tools/list\",\"params\":{}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"id\":202")); - ASSERT_NULL(strstr(resp, "\"nextCursor\"")); - ASSERT_NOT_NULL(strstr(resp, "manage_adr")); - ASSERT_NOT_NULL(strstr(resp, "ingest_traces")); - free(resp); - - resp = cbm_mcp_server_handle( + char *cursor_resp = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{\"cursor\":\"8\"}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"id\":201")); - ASSERT_NULL(strstr(resp, "\"nextCursor\"")); - ASSERT_NOT_NULL(strstr(resp, "manage_adr")); - free(resp); + cbm_unsetenv("CBM_TOOL_MODE"); + + ASSERT_NOT_NULL(full_resp); + ASSERT_NOT_NULL(strstr(full_resp, "\"id\":200")); + ASSERT_NULL(strstr(full_resp, "\"nextCursor\"")); + ASSERT_NOT_NULL(strstr(full_resp, "index_repository")); + ASSERT_NOT_NULL(strstr(full_resp, "manage_adr")); + ASSERT_NOT_NULL(strstr(full_resp, "ingest_traces")); + + ASSERT_NOT_NULL(empty_params_resp); + ASSERT_NOT_NULL(strstr(empty_params_resp, "\"id\":202")); + ASSERT_NULL(strstr(empty_params_resp, "\"nextCursor\"")); + ASSERT_NOT_NULL(strstr(empty_params_resp, "manage_adr")); + ASSERT_NOT_NULL(strstr(empty_params_resp, "ingest_traces")); + + ASSERT_NOT_NULL(cursor_resp); + ASSERT_NOT_NULL(strstr(cursor_resp, "\"id\":201")); + ASSERT_NULL(strstr(cursor_resp, "\"nextCursor\"")); + ASSERT_NOT_NULL(strstr(cursor_resp, "manage_adr")); + + free(full_resp); + free(empty_params_resp); + free(cursor_resp); cbm_mcp_server_free(srv); PASS(); @@ -1537,6 +1529,8 @@ TEST(server_handle_unknown_method) { * TOOL HANDLERS (via server_handle) * ══════════════════════════════════════════════════════════════════ */ +static char *extract_text_content(const char *mcp_result); + /* Helper: create a server with an in-memory store populated with test data */ static cbm_mcp_server_t *setup_mcp_with_data(void) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); /* NULL = in-memory */ @@ -1559,1808 +1553,2020 @@ TEST(tool_list_projects_empty) { PASS(); } -TEST(tool_get_graph_schema_empty) { - cbm_mcp_server_t *srv = setup_mcp_with_data(); +TEST(tool_list_projects_includes_tmp_prefixed_project) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-list-tmp-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails */ + } - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_graph_schema\",\"arguments\":{}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"result\"")); - free(resp); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_auto = getenv("CBM_AUTO_INDEX"); + char *saved_auto_copy = saved_auto ? strdup(saved_auto) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_AUTO_INDEX", "false", 1); - cbm_mcp_server_free(srv); - PASS(); -} + char db_path[512]; + int db_len = snprintf(db_path, sizeof(db_path), "%s/tmp-valid-project.db", cache); + ASSERT_TRUE(db_len > 0 && (size_t)db_len < sizeof(db_path)); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "tmp-valid-project", "/tmp/valid-project"), + CBM_STORE_OK); + cbm_store_close(store); -TEST(tool_unknown_tool) { cbm_mcp_server_t *srv = setup_mcp_with_data(); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"nonexistent_tool\",\"arguments\":{}}}"); + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); ASSERT_NOT_NULL(resp); - /* Should return result with isError */ - ASSERT_NOT_NULL(strstr(resp, "isError")); + ASSERT_NOT_NULL(strstr(resp, "tmp-valid-project")); free(resp); - cbm_mcp_server_free(srv); + + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + if (saved_auto_copy) { + cbm_setenv("CBM_AUTO_INDEX", saved_auto_copy, 1); + free(saved_auto_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + cbm_unlink(db_path); + char wal[512]; + char shm[512]; + int wal_len = snprintf(wal, sizeof(wal), "%s-wal", db_path); + int shm_len = snprintf(shm, sizeof(shm), "%s-shm", db_path); + if (wal_len > 0 && (size_t)wal_len < sizeof(wal)) { + cbm_unlink(wal); + } + if (shm_len > 0 && (size_t)shm_len < sizeof(shm)) { + cbm_unlink(shm); + } + cbm_rmdir(cache); PASS(); } -TEST(tool_search_graph_basic) { - cbm_mcp_server_t *srv = setup_mcp_with_data(); +#ifdef _WIN32 +/* Project discovery and query resolution validate each database before opening + * it. Keep that validation on the same UTF-8 path contract as the store: one + * CJK cache path must work end-to-end for both surfaces. Fixture setup and + * teardown are O(P) in the path length plus the normal O(database pages) store + * work, with no retained allocation beyond the server/store lifetimes. */ +TEST(tool_list_and_query_projects_in_cjk_cache_path_windows) { + char *temporary = th_mktempdir("cbm-mcp-cjk-cache"); + ASSERT_NOT_NULL(temporary); + char temporary_copy[CBM_SZ_1K]; + ASSERT_TRUE(snprintf(temporary_copy, sizeof(temporary_copy), "%s", temporary) > 0); + + char cache[CBM_SZ_1K]; + int written = snprintf(cache, sizeof(cache), "%s/%s", temporary_copy, + "\xE4\xB8\xAD\xE6\x96\x87\xE7\xBC\x93\xE5\xAD\x98"); + ASSERT_TRUE(written > 0 && (size_t)written < sizeof(cache)); + ASSERT_EQ(th_mkdir_p(cache), 0); + + static const char project[] = "cjk-cache-project"; + char db_path[CBM_SZ_1K]; + written = snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + ASSERT_TRUE(written > 0 && (size_t)written < sizeof(db_path)); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, temporary_copy), CBM_STORE_OK); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "CjkCacheVisible", + .qualified_name = "cjk.cache.CjkCacheVisible", + .file_path = "src/cache.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + cbm_store_close(store); - /* search_graph with no project → should work on empty store */ - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"label\":\"Function\",\"limit\":10}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"result\"")); - free(resp); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + const char *saved_auto = getenv("CBM_AUTO_INDEX"); + char *saved_auto_copy = saved_auto ? cbm_strdup(saved_auto) : NULL; + bool environment_ready = cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_setenv("CBM_AUTO_INDEX", "false", 1) == 0; - cbm_mcp_server_free(srv); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + bool server_ready = srv != NULL; + char *response = srv ? cbm_mcp_handle_tool(srv, "list_projects", "{}") : NULL; + char *inner = response ? extract_text_content(response) : NULL; + bool list_ready = inner && strstr(inner, project); + free(inner); + free(response); + + response = srv ? cbm_mcp_handle_tool( + srv, "query_graph", + "{\"project\":\"cjk-cache-project\"," + "\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 1\"}") + : NULL; + inner = response ? extract_text_content(response) : NULL; + bool query_ready = inner && strstr(inner, "CjkCacheVisible") && + !strstr(inner, "project not found"); + free(inner); + free(response); + if (srv) { + cbm_mcp_server_free(srv); + } + + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + if (saved_auto_copy) { + ASSERT_EQ(cbm_setenv("CBM_AUTO_INDEX", saved_auto_copy, 1), 0); + } else { + ASSERT_EQ(cbm_unsetenv("CBM_AUTO_INDEX"), 0); + } + free(saved_auto_copy); + th_cleanup(temporary_copy); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(server_ready); + ASSERT_TRUE(list_ready); + ASSERT_TRUE(query_ready); PASS(); } +#endif -/* Forward declarations for helpers defined later in this file */ -static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz); -static void cleanup_snippet_dir(const char *tmp_dir); -static char *extract_text_content(const char *mcp_result); +TEST(tool_list_projects_first_context_resolves_session_store) { + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-list-context-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); -/* callers_total/callees_total must count what the caller can enumerate: with - * include_tests=false (default) test-file rows are hidden from the table, so - * the totals must apply the same filter — a raw visited_count overstated the - * set (field-eval agent read callers_total=175 against 2 visible rows and - * distrusted the tool). */ -TEST(tool_trace_totals_respect_test_filter) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "totproj"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/tot"); + char repo[CBM_SZ_512]; + ASSERT_TRUE(snprintf(repo, sizeof(repo), "%s/repo", cache) > 0); + ASSERT_EQ(th_mkdir_p(repo), 0); + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); - cbm_node_t tgt = {.project = proj, - .label = "Function", - .name = "tgt", - .qualified_name = "totproj.a.tgt", - .file_path = "a.c", - .start_line = 1, - .end_line = 5}; - int64_t tid = cbm_store_upsert_node(st, &tgt); - ASSERT_GT(tid, 0); - cbm_node_t prod = {.project = proj, - .label = "Function", - .name = "prod_caller", - .qualified_name = "totproj.a.prod_caller", - .file_path = "a.c", - .start_line = 10, - .end_line = 15}; - int64_t pid = cbm_store_upsert_node(st, &prod); - ASSERT_GT(pid, 0); - cbm_node_t tst = {.project = proj, - .label = "Function", - .name = "test_caller", - .qualified_name = "totproj.t.test_caller", - .file_path = "tests/test_x.c", - .start_line = 1, - .end_line = 5}; - int64_t xid = cbm_store_upsert_node(st, &tst); - ASSERT_GT(xid, 0); - cbm_edge_t e1 = {.project = proj, .source_id = pid, .target_id = tid, .type = "CALLS"}; - ASSERT_GT(cbm_store_insert_edge(st, &e1), 0); - cbm_edge_t e2 = {.project = proj, .source_id = xid, .target_id = tid, .type = "CALLS"}; - ASSERT_GT(cbm_store_insert_edge(st, &e2), 0); + char db_path[CBM_SZ_1K]; + ASSERT_TRUE(snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project) > 0); + cbm_store_t *indexed_store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(indexed_store); + ASSERT_EQ(cbm_store_upsert_project(indexed_store, project, repo), CBM_STORE_OK); + cbm_node_t node = {.project = project, + .label = "Project", + .name = project, + .qualified_name = project, + .file_path = ""}; + ASSERT_GT(cbm_store_upsert_node(indexed_store, &node), 0); + cbm_store_close(indexed_store); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\"," - "\"function_name\":\"tgt\",\"direction\":\"inbound\"}}}"); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, repo)); + char *resp = cbm_mcp_handle_tool(srv, "list_projects", "{}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); - free(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "callers_total: 1")); /* test row filtered */ - free(inner); + ASSERT_NOT_NULL(strstr(inner, project)); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"ready\"")); + ASSERT_NOT_NULL(strstr(inner, "\"nodes\":1")); + ASSERT_NULL(strstr(inner, "\"status\":\"not_indexed\"")); - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\"," - "\"function_name\":\"tgt\",\"direction\":\"inbound\",\"include_tests\":true}}}"); - ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - free(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "callers_total: 2")); /* both visible now */ free(inner); + free(resp); cbm_mcp_server_free(srv); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + cbm_remove_db_sidecars(db_path); + cbm_unlink(db_path); + free(project); + th_rmtree(cache); PASS(); } -/* SCC condensation (get_architecture aspect "cycles"): a 3-function CALLS - * cycle A->B->C->A must be reported as one circular dependency of size 3 with - * all three members; a separate acyclic chain (D->E) must NOT appear. The - * aspect is opt-in — a default get_architecture call must NOT compute it. */ -TEST(tool_get_architecture_cycles_detects_scc) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "cycproj"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/cyc"); +typedef struct { + const char *project; + const char *status; + bool graph_published; +} coordinated_index_result_spec_t; + +static char *coordinated_index_target_result(void *context, const char *repo_path, + const char *args_json) { + (void)repo_path; + (void)args_json; + const coordinated_index_result_spec_t *spec = context; + char payload[CBM_SZ_512]; + (void)snprintf(payload, sizeof(payload), + "{\"project\":\"%s\",\"status\":\"%s\"," + "\"graph_published\":%s}", + spec->project, spec->status, spec->graph_published ? "true" : "false"); + return cbm_mcp_text_result(payload, false); +} + +TEST(tool_index_repository_first_context_uses_published_target_project) { + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-context-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - const char *names[5] = {"A", "B", "C", "D", "E"}; - int64_t id[5]; - for (int i = 0; i < 5; i++) { - char qn[32]; - snprintf(qn, sizeof(qn), "cycproj.m.%s", names[i]); - cbm_node_t n = {.project = proj, - .label = "Function", - .name = names[i], - .qualified_name = qn, - .file_path = "m.c", - .start_line = i + 1, - .end_line = i + 2}; - id[i] = cbm_store_upsert_node(st, &n); - ASSERT_GT(id[i], 0); - } - /* cycle A->B->C->A, plus acyclic D->E */ - struct { - int f; - int t; - } e[] = {{0, 1}, {1, 2}, {2, 0}, {3, 4}}; - for (size_t i = 0; i < sizeof(e) / sizeof(e[0]); i++) { - cbm_edge_t ed = { - .project = proj, .source_id = id[e[i].f], .target_id = id[e[i].t], .type = "CALLS"}; - ASSERT_GT(cbm_store_insert_edge(st, &ed), 0); - } + const char *project = "coordinated-index-target"; + char db_path[CBM_SZ_512]; + ASSERT_TRUE(snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project) > 0); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, cache), CBM_STORE_OK); + cbm_node_t node = {.project = project, + .label = "Project", + .name = project, + .qualified_name = project, + .file_path = ""}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + cbm_store_close(store); - /* opt-in cycles aspect */ - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"," - "\"aspects\":[\"cycles\"]}}}"); - ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "cycles: 1")); /* exactly one SCC of size>1 */ - ASSERT_NOT_NULL(strstr(inner, "cycproj.m.A")); - ASSERT_NOT_NULL(strstr(inner, "cycproj.m.B")); - ASSERT_NOT_NULL(strstr(inner, "cycproj.m.C")); - ASSERT_NULL(strstr(inner, "cycproj.m.D")); /* acyclic node not in any cycle */ - free(inner); - free(resp); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); - /* default call (no aspects) must NOT run the scan. */ - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"}}}"); - ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "caller-session-project"); + coordinated_index_result_spec_t result_spec = { + .project = project, + .status = "indexed", + .graph_published = true, + }; + cbm_mcp_server_set_index_executor(srv, coordinated_index_target_result, &result_spec); + + char *response = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\"/tmp\"}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); ASSERT_NOT_NULL(inner); - ASSERT_NULL(strstr(inner, "cycles:")); + ASSERT_NOT_NULL(strstr(inner, "\"session_project\":\"caller-session-project\"")); + ASSERT_NOT_NULL(strstr(inner, "\"project\":\"coordinated-index-target\"")); + ASSERT_NOT_NULL(strstr(inner, "\"_context\":{\"project\":\"coordinated-index-target\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"ready\"")); + ASSERT_NOT_NULL(strstr(inner, "\"nodes\":1")); + ASSERT_NULL(strstr(inner, "\"action_required\"")); + free(inner); - free(resp); + free(response); cbm_mcp_server_free(srv); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + cbm_remove_db_sidecars(db_path); + cbm_unlink(db_path); + th_rmtree(cache); PASS(); } -/* Context-bomb guard: get_code_snippet on a whole-file node (a Module/File - * span) used to read the ENTIRE file into one response — a field-eval agent - * that fell back to a Module snippet pulled ~400KB in a single call. The read - * must clip at MCP_SNIPPET_MAX_LINES and flag source_clipped, while the exact - * start/end range stays in the response for a targeted re-read. */ -TEST(tool_get_code_snippet_clips_whole_file_node) { - char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_snipcap_XXXXXX"); - ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); - char proj_dir[512]; - snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp); - cbm_mkdir(proj_dir); - char src_path[600]; - snprintf(src_path, sizeof(src_path), "%s/big.py", proj_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - enum { BIG_LINES = 2000 }; - for (int i = 0; i < BIG_LINES; i++) { - fprintf(fp, "line_%04d = %d # padding to blow up an unclipped read\n", i, i); - } - fclose(fp); - +TEST(tool_index_repository_unpublished_result_keeps_session_context) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "test-project"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, proj_dir); - - cbm_node_t mod = {0}; - mod.project = proj; - mod.label = "Module"; - mod.name = "big"; - mod.qualified_name = "test-project.big"; - mod.file_path = "big.py"; - mod.start_line = 1; - mod.end_line = BIG_LINES; - ASSERT_GT(cbm_store_upsert_node(st, &mod), 0); + cbm_mcp_server_set_session_project(srv, "caller-session-project"); + coordinated_index_result_spec_t result_spec = { + .project = "coordinated-index-target", + .status = "queued", + .graph_published = false, + }; + cbm_mcp_server_set_index_executor(srv, coordinated_index_target_result, &result_spec); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"get_code_snippet\",\"arguments\":{\"project\":\"test-project\"," - "\"qualified_name\":\"test-project.big\"}}}"); - ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); + char *response = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\"/tmp\"}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"source_clipped\":true")); - /* The whole 2000-line file (~100KB) must NOT be in the response. */ - ASSERT_TRUE(strlen(inner) < 60000); - /* The last line must be absent (clipped), the first present. */ - ASSERT_NOT_NULL(strstr(inner, "line_0000")); - ASSERT_NULL(strstr(inner, "line_1999")); + ASSERT_NOT_NULL(strstr(inner, "\"project\":\"coordinated-index-target\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"queued\"")); + ASSERT_NOT_NULL(strstr(inner, "\"_context\":{\"project\":\"caller-session-project\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"not_indexed\"")); + ASSERT_NOT_NULL(strstr(inner, "\"action_required\"")); + ASSERT_NULL(strstr(inner, "\"_context\":{\"project\":\"coordinated-index-target\"")); + free(inner); - free(resp); + free(response); cbm_mcp_server_free(srv); - th_rmtree(tmp); PASS(); } -TEST(tool_search_graph_includes_node_properties) { - /* Node properties are OPT-IN columns in the default TOON output: the - * default row is qn/label/file/lines/degrees only, `fields` adds the - * requested property columns, and format:"json" restores the legacy - * verbose objects with the full property blob. The setup_snippet_server - * inserts HandleRequest with a signature/return_type/is_exported blob. */ - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +TEST(response_context_disabled_does_not_consume_first_delivery) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "internal-worker-context"); + cbm_mcp_server_set_response_context(srv, false); + + char *internal = cbm_mcp_handle_tool(srv, "search_graph", "{\"name_pattern\":\"nothing\"}"); + ASSERT_NOT_NULL(internal); + ASSERT_NULL(strstr(internal, "\\\"_context\\\":")); + ASSERT_NULL(strstr(internal, "session_project")); + free(internal); + + /* Suppression is transport ownership, not consumption: once this server is + * made client-facing, its first response still carries the automatic block. */ + cbm_mcp_server_set_response_context(srv, true); + char *external = cbm_mcp_handle_tool(srv, "search_graph", "{\"name_pattern\":\"nothing\"}"); + ASSERT_NOT_NULL(external); + ASSERT_NOT_NULL(strstr(external, "\\\"_context\\\":")); + ASSERT_NOT_NULL(strstr(external, "session_project")); + free(external); - /* Default TOON: compact table, no property spill. */ - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," - "\"name_pattern\":\"HandleRequest\",\"limit\":5}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"structuredContent\":{\"text\":")); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "results:")); /* TOON table header */ - ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out;")); - ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); - ASSERT_NULL(strstr(inner, "func HandleRequest")); /* signature not spilled */ - ASSERT_NULL(strstr(inner, "is_exported")); - free(inner); - free(resp); + cbm_mcp_server_free(srv); + PASS(); +} - /* fields:["signature"] adds the column + values. */ - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," - "\"name_pattern\":\"HandleRequest\",\"fields\":[\"signature\"],\"limit\":5}}}"); - ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out signature;")); - /* values with spaces are QUOTED so column positions survive */ - ASSERT_NOT_NULL(strstr(inner, "\"func HandleRequest() error\"")); - ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); - free(inner); - free(resp); +TEST(tool_list_projects_paginates_with_explicit_full_compatibility) { + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-list-page-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } - /* format:"json" = json-stringified tree: same grouped model, column- - * ordered row arrays — never per-row key envelopes or property blobs. - * fields adds columns there too. */ - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," - "\"name_pattern\":\"HandleRequest\",\"format\":\"json\"," - "\"fields\":[\"signature\"],\"limit\":5}}}"); - ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"qn_prefix\"")); /* grouped tree model */ - ASSERT_NOT_NULL(strstr(inner, "\"cols\"")); - ASSERT_NOT_NULL(strstr(inner, "\"rows\"")); - ASSERT_NOT_NULL(strstr(inner, "\"signature\"")); /* requested column */ - ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); /* its value */ - ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */ - free(inner); - free(resp); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + const char *names[] = {"charlie-page", "alpha-page", "bravo-page"}; + bool setup_ok = true; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + char path[CBM_SZ_512]; + int n = snprintf(path, sizeof(path), "%s/%s.db", cache, names[i]); + cbm_store_t *store = n > 0 && (size_t)n < sizeof(path) ? cbm_store_open_path(path) : NULL; + if (!store || cbm_store_upsert_project(store, names[i], cache) != CBM_STORE_OK) { + setup_ok = false; + } + cbm_store_close(store); + } + + bool first_page_ok = false; + bool second_page_ok = false; + bool full_compat_ok = false; + bool schema_ok = false; + if (setup_ok) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + char *first = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":2}"); + char *first_text = first ? extract_text_content(first) : NULL; + yyjson_doc *first_doc = + first_text ? yyjson_read(first_text, strlen(first_text), 0) : NULL; + if (first_doc) { + yyjson_val *root = yyjson_doc_get_root(first_doc); + yyjson_val *projects = yyjson_obj_get(root, "projects"); + yyjson_val *p0 = projects ? yyjson_arr_get(projects, 0) : NULL; + yyjson_val *p1 = projects ? yyjson_arr_get(projects, 1) : NULL; + first_page_ok = + projects && yyjson_arr_size(projects) == 2 && + strcmp(yyjson_get_str(yyjson_obj_get(p0, "name")), "alpha-page") == 0 && + strcmp(yyjson_get_str(yyjson_obj_get(p1, "name")), "bravo-page") == 0 && + yyjson_get_bool(yyjson_obj_get(root, "has_more")) && + yyjson_get_int(yyjson_obj_get(root, "next_offset")) == 2; + yyjson_doc_free(first_doc); + } + free(first_text); + free(first); + + char *second = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":2,\"offset\":2}"); + char *second_text = second ? extract_text_content(second) : NULL; + yyjson_doc *second_doc = + second_text ? yyjson_read(second_text, strlen(second_text), 0) : NULL; + if (second_doc) { + yyjson_val *root = yyjson_doc_get_root(second_doc); + yyjson_val *projects = yyjson_obj_get(root, "projects"); + yyjson_val *p0 = projects ? yyjson_arr_get(projects, 0) : NULL; + second_page_ok = + projects && yyjson_arr_size(projects) == 1 && + strcmp(yyjson_get_str(yyjson_obj_get(p0, "name")), "charlie-page") == 0 && + !yyjson_get_bool(yyjson_obj_get(root, "has_more")); + yyjson_doc_free(second_doc); + } + free(second_text); + free(second); + + char *full = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":1,\"all\":true}"); + char *full_text = full ? extract_text_content(full) : NULL; + yyjson_doc *full_doc = full_text ? yyjson_read(full_text, strlen(full_text), 0) : NULL; + if (full_doc) { + yyjson_val *projects = yyjson_obj_get(yyjson_doc_get_root(full_doc), "projects"); + full_compat_ok = projects && yyjson_arr_size(projects) == 3; + yyjson_doc_free(full_doc); + } + free(full_text); + free(full); + cbm_mcp_server_free(srv); + } + + const char *schema = cbm_mcp_tool_input_schema("list_projects"); + schema_ok = schema && strstr(schema, "\"limit\"") && strstr(schema, "\"offset\"") && + strstr(schema, "\"all\""); + } + + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(cache); + + ASSERT_TRUE(setup_ok); + ASSERT_TRUE(first_page_ok); + ASSERT_TRUE(second_page_ok); + ASSERT_TRUE(full_compat_ok); + ASSERT_TRUE(schema_ok); PASS(); } -TEST(tool_output_byte_budgets) { - /* GUARD: absolute byte ceilings on default tool outputs. Re-bloat (e.g. - * a property blob sneaking back into row emission — the fp field alone - * is ~450B/hit) blows these ceilings immediately. The numbers are - * generous vs the measured compact outputs (search hit rows ≈ 90B) but - * far below the legacy verbose sizes (≈1.5KB/hit). */ - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); +/* Defined with the other corrupt-store helpers further down; forward-declared + * so this earlier test can locate a quarantine backup by pattern rather than by + * a fixed filename. */ +static int mcp_find_corrupt_backups(const char *cache, const char *project, char *unique_path, + size_t unique_path_size); - /* search_graph: 1-hit search must stay under 600B. */ - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":46,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," - "\"name_pattern\":\"HandleRequest\",\"limit\":5}}}"); - ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); /* non-vacuous: hit present */ - ASSERT_LT((int)strlen(inner), 600); - free(inner); - free(resp); +TEST(resolve_store_quarantines_structurally_corrupt_db) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-corrupt-quarantine-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails */ + } - /* trace_path: single-hop trace on the fixture must stay under 800B. */ - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":47,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\"," - "\"arguments\":{\"project\":\"test-project\",\"function_name\":\"HandleRequest\"," - "\"direction\":\"both\",\"depth\":2}}}"); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_auto = getenv("CBM_AUTO_INDEX"); + char *saved_auto_copy = saved_auto ? strdup(saved_auto) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_AUTO_INDEX", "false", 1); + + char db_path[512]; + int db_len = snprintf(db_path, sizeof(db_path), "%s/corrupt-project.db", cache); + ASSERT_TRUE(db_len > 0 && (size_t)db_len < sizeof(db_path)); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + sqlite3 *db = cbm_store_get_db(store); + ASSERT_NOT_NULL(db); + ASSERT_EQ(sqlite3_exec(db, "DROP TABLE projects;", NULL, NULL, NULL), SQLITE_OK); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":" + "\"check_index_coverage\",\"arguments\":{\"project\":\"corrupt-project\"," + "\"paths\":[\"src/main.c\"]}}}"); ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "callees:")); - ASSERT_LT((int)strlen(inner), 800); - free(inner); free(resp); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); -} -TEST(tool_search_graph_toon_never_leaks_internal_fields) { - /* The similarity/semantic pipeline intermediates (fp minhash hex, sp - * structural profile, bt body-token bag) dominated the legacy payload - * (~45%) and carry zero agent value. GUARD: they never appear in TOON - * output — not by default and not even when explicitly requested via - * fields (blocklist). */ - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); + /* Asserted as "a quarantined copy exists", not as one filename. + * + * This previously required the fixed path ".corrupt". The merged + * quarantine is upstream's and reserves a UNIQUE backup name + * (reserve_unique_corrupt_pending, src/mcp/mcp.c:4041+), which is the + * stronger behavior and is itself pinned by + * tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name: + * a fixed name silently OVERWRITES the previous quarantine the second time + * a project corrupts, destroying the earlier copy — data loss of exactly + * the kind this branch's own #557 fix exists to prevent. Locate the backup + * by pattern, the same way the other corrupt-store tests do. */ + ASSERT_FALSE(test_file_exists_mcp(db_path)); + char quarantine[CBM_SZ_1K] = {0}; + int quarantine_count = + mcp_find_corrupt_backups(cache, "corrupt-project", quarantine, sizeof(quarantine)); + ASSERT_EQ(quarantine_count, 1); + ASSERT_TRUE(quarantine[0] != '\0'); - /* A node whose properties carry the internal fields with sentinels. */ - cbm_node_t n = {0}; - n.project = "test-project"; - n.label = "Function"; - n.name = "fpCarrier"; - n.qualified_name = "test-project.src.fpCarrier"; - n.file_path = "src/fp.go"; - n.start_line = 1; - n.end_line = 2; - n.properties_json = "{\"fp\":\"FPSENTINEL00\",\"sp\":\"SPSENTINEL00\"," - "\"bt\":\"BTSENTINEL00\",\"complexity\":7}"; - ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + if (saved_auto_copy) { + cbm_setenv("CBM_AUTO_INDEX", saved_auto_copy, 1); + free(saved_auto_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + cbm_unlink(quarantine); + cbm_rmdir(cache); + PASS(); +} + +TEST(resolve_store_leaves_foreign_sqlite_db_untouched) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-foreign-db-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails */ + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_auto = getenv("CBM_AUTO_INDEX"); + char *saved_auto_copy = saved_auto ? strdup(saved_auto) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_AUTO_INDEX", "false", 1); + + char db_path[512]; + int db_len = snprintf(db_path, sizeof(db_path), "%s/foreign-project.db", cache); + ASSERT_TRUE(db_len > 0 && (size_t)db_len < sizeof(db_path)); + sqlite3 *foreign_db = NULL; + ASSERT_EQ(sqlite3_open(db_path, &foreign_db), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(foreign_db, "CREATE TABLE user_data(id INTEGER PRIMARY KEY);", NULL, + NULL, NULL), + SQLITE_OK); + sqlite3_close(foreign_db); + + char quarantine[512]; + char wal[512]; + char shm[512]; + int quarantine_len = snprintf(quarantine, sizeof(quarantine), "%s.corrupt", db_path); + int wal_len = snprintf(wal, sizeof(wal), "%s-wal", db_path); + int shm_len = snprintf(shm, sizeof(shm), "%s-shm", db_path); + ASSERT_TRUE(quarantine_len > 0 && (size_t)quarantine_len < sizeof(quarantine)); + ASSERT_TRUE(wal_len > 0 && (size_t)wal_len < sizeof(wal)); + ASSERT_TRUE(shm_len > 0 && (size_t)shm_len < sizeof(shm)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":45,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project\":\"test-project\",\"name_pattern\":\"fpCarrier\"," - "\"fields\":[\"fp\",\"sp\",\"bt\",\"complexity\"],\"limit\":5}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":" + "\"search_graph\",\"arguments\":{\"project\":\"foreign-project\"," + "\"pattern\":\"anything\"}}}"); ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "fpCarrier")); - ASSERT_NULL(strstr(inner, "FPSENTINEL00")); - ASSERT_NULL(strstr(inner, "SPSENTINEL00")); - ASSERT_NULL(strstr(inner, "BTSENTINEL00")); - /* Non-blocked requested field still comes through. */ - ASSERT_NOT_NULL(strstr(inner, "complexity")); - free(inner); free(resp); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + + ASSERT_TRUE(test_file_exists_mcp(db_path)); + ASSERT_FALSE(test_file_exists_mcp(quarantine)); + ASSERT_FALSE(test_file_exists_mcp(wal)); + ASSERT_FALSE(test_file_exists_mcp(shm)); + + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + if (saved_auto_copy) { + cbm_setenv("CBM_AUTO_INDEX", saved_auto_copy, 1); + free(saved_auto_copy); + } else { + cbm_unsetenv("CBM_AUTO_INDEX"); + } + cbm_unlink(db_path); + cbm_rmdir(cache); PASS(); } -TEST(tool_lean_defaults_schema_and_status) { - /* GUARDS for the lean-default contract (TOON round 2): - * 1. get_graph_schema must not advertise the blocked internal fields - * (fp/sp/bt) — the server refuses to emit them, so listing them in the - * schema invited agents to request fields they can never get. - * 2. index_status omits the git context block unless verbose:true — the - * worktree/shadow path variants only matter when debugging where an - * index lives. */ - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); - - cbm_node_t n = {0}; - n.project = "test-project"; - n.label = "Function"; - n.name = "schemaCarrier"; - n.qualified_name = "test-project.src.schemaCarrier"; - n.file_path = "src/sc.go"; - n.start_line = 1; - n.end_line = 2; - n.properties_json = "{\"fp\":\"x\",\"sp\":\"y\",\"bt\":\"z\",\"complexity\":3}"; - ASSERT_GT(cbm_store_upsert_node(st, &n), 0); +TEST(tool_get_graph_schema_empty) { + cbm_mcp_server_t *srv = setup_mcp_with_data(); char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_graph_schema\"," - "\"arguments\":{\"project\":\"test-project\"}}}"); - ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "Function")); /* non-vacuous: label present */ - ASSERT_NOT_NULL(strstr(inner, "complexity")); /* obtainable property listed */ - ASSERT_NULL(strstr(inner, "\"fp\"")); /* blocked fields not advertised */ - ASSERT_NULL(strstr(inner, "\"sp\"")); - ASSERT_NULL(strstr(inner, "\"bt\"")); - free(inner); - free(resp); - - /* index_status: no git block by default... */ - resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":49,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_status\"," - "\"arguments\":{\"project\":\"test-project\"}}}"); - ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"status\"")); - ASSERT_NULL(strstr(inner, "\"git\"")); - free(inner); - free(resp); - - /* ...and present with verbose:true. */ - resp = cbm_mcp_server_handle(srv, - "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_status\"," - "\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}"); + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_graph_schema\",\"arguments\":{}}}"); ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"git\"")); - free(inner); + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); free(resp); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -/* ── Tool-output regression suite (gating) ────────────────────────── - * Context-explosion detector: flags the measured smells that re-introduce - * token bloat into default outputs, independent of any specific tool: - * 1. blocked internal fields (fp/sp/bt) appearing anywhere; - * 2. repeated-key JSON envelopes — the same key emitted per row instead of - * a header-once table (the un-TOONed enumeration smell; detect_changes - * shipped 4,787x3 of these = 416KB); - * 3. embedded prose notes/hints beyond one line (~220 chars) — long prose - * belongs in tool descriptions or docs, not repeated per response. - * Returns NULL when clean, else a static description of the violation. */ -static const char *output_explosion_smell(const char *inner) { - static const char *row_keys[] = { - "\"name\":", "\"label\":", "\"file\":", "\"path\":", "\"qualified_name\":", "\"qn\":"}; - if (strstr(inner, "\"fp\":") || strstr(inner, "\"sp\":") || strstr(inner, "\"bt\":")) { - return "blocked internal field (fp/sp/bt) leaked into output"; - } - for (size_t k = 0; k < sizeof(row_keys) / sizeof(row_keys[0]); k++) { - int n = 0; - for (const char *p = strstr(inner, row_keys[k]); p && n <= 32; - p = strstr(p + 1, row_keys[k])) { - n++; - } - if (n > 32) { - return "repeated-key envelope (>32x same JSON key) — emit a header-once table"; - } - } - for (const char *p = strstr(inner, "\"note\":\""); p; p = strstr(p + 1, "\"note\":\"")) { - const char *end = strchr(p + 9, '"'); - while (end && end[-1] == '\\') { - end = strchr(end + 1, '"'); - } - if (end && end - (p + 9) > 220) { - return "embedded note exceeds one line (~220 chars)"; - } - } - return NULL; -} +TEST(tool_get_graph_schema_uses_ready_overlay_schema) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); -/* Run one tool call on the fixture server, apply the explosion detector and - * an absolute byte ceiling, and require a semantic-floor marker so trimming - * can never hollow the response out either. */ -static const char *check_tool_output(cbm_mcp_server_t *srv, const char *req, int ceiling, - const char *floor_marker) { - char *resp = cbm_mcp_server_handle(srv, req); - if (!resp) { - return "no response"; - } - char *inner = extract_text_content(resp); - free(resp); - if (!inner) { - return "no text content"; - } - static char why[256]; - const char *smell = output_explosion_smell(inner); - if (smell) { - snprintf(why, sizeof(why), "%s", smell); - free(inner); - return why; - } - if ((int)strlen(inner) >= ceiling) { - snprintf(why, sizeof(why), "output %d B >= ceiling %d B", (int)strlen(inner), ceiling); - free(inner); - return why; - } - if (floor_marker && !strstr(inner, floor_marker)) { - snprintf(why, sizeof(why), "semantic floor missing: %s", floor_marker); - free(inner); - return why; - } - free(inner); - return NULL; -} + const char *proj = "graph-schema-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/graph-schema-overlay"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); -TEST(tool_output_regression_gate) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldGraphSchema", + .qualified_name = "graph.schema.OldGraphSchema", + .file_path = "src/main.c", + .properties_json = "{\"old_role\":true}"}; + cbm_node_t stable = {.project = proj, + .label = "Class", + .name = "StableGraphSchema", + .qualified_name = "graph.schema.StableGraphSchema", + .file_path = "src/stable.c", + .properties_json = "{\"stable_role\":true}"}; + int64_t old_fn_id = cbm_store_upsert_node(st, &old_fn); + int64_t stable_id = cbm_store_upsert_node(st, &stable); + ASSERT_GT(old_fn_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_fn_id, + .target_id = stable_id, + .type = "CALLS", + .properties_json = "{\"old_edge\":true}"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_class = {.project = proj, + .label = "Class", + .name = "FreshGraphSchema", + .qualified_name = "graph.schema.FreshGraphSchema", + .file_path = "src/main.c", + .properties_json = "{\"fresh_role\":true}"}; + cbm_store_delta_edge_t fresh_edge = {.source_qn = "graph.schema.FreshGraphSchema", + .target_qn = "graph.schema.StableGraphSchema", + .type = "HANDLES", + .properties_json = "{\"fresh_edge\":true}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &fresh_class, + .node_count = 1, + .edges = &fresh_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - struct { - const char *req; - int ceiling; - const char *floor; - } cases[] = { - {"{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," - "\"name_pattern\":\".*\",\"limit\":50}}}", - 6000, "results:"}, - {"{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"get_graph_schema\",\"arguments\":{\"project\":\"test-project\"}}}", - 6000, "node_labels"}, - {"{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"index_status\",\"arguments\":{\"project\":\"test-project\"}}}", - 7000, "\"status\""}, - {"{\"jsonrpc\":\"2.0\",\"id\":73,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"test-project\"," - "\"function_name\":\"HandleRequest\",\"direction\":\"both\"}}}", - 1500, "callees:"}, - }; - for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { - const char *why = check_tool_output(srv, cases[i].req, cases[i].ceiling, cases[i].floor); - if (why) { - char msg[320]; - snprintf(msg, sizeof(msg), "case %d: %s", (int)i, why); - FAIL(msg); - } - } + /* format=json: this test pins the legacy JSON schema shape (escaped + * "label":"Class" etc below); default_response_format is toon. */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_graph_schema\"," + "\"arguments\":{\"project\":\"graph-schema-overlay\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\\\"label\\\":\\\"Function\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"label\\\":\\\"Class\\\"")); + ASSERT_NOT_NULL(strstr(resp, "fresh_role")); + ASSERT_NULL(strstr(resp, "old_role")); + ASSERT_NOT_NULL(strstr(resp, "\\\"type\\\":\\\"HANDLES\\\"")); + ASSERT_NULL(strstr(resp, "\\\"type\\\":\\\"CALLS\\\"")); + ASSERT_NOT_NULL(strstr(resp, "fresh_edge")); + ASSERT_NULL(strstr(resp, "old_edge")); + ASSERT_NOT_NULL(strstr(resp, "\\\"read_model\\\":\\\"overlay_active_graph\\\"")); + ASSERT_NOT_NULL(strstr(resp, "node_properties")); + ASSERT_NOT_NULL(strstr(resp, "edge_properties")); + ASSERT_NOT_NULL(strstr(resp, "\\\"active_file_tombstones\\\":1")); + free(resp); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -TEST(tool_search_graph_query_honors_file_pattern_issue552) { +/* T14 (schema call-graph audit 2026-07-19): the first-response _context must + * read the same overlay-aware view as query_graph and get_graph_schema. RED + * against the pre-fix inject_context_once, which read canonical-only + * cbm_store_get_schema and could advertise a label (Function below) whose + * rows are all tombstoned in the active overlay — vocabulary query_graph + * would then contradict on the very next call. Same overlay fixture shape as + * tool_get_graph_schema_uses_ready_overlay_schema above. */ +TEST(first_response_context_uses_ready_overlay_schema) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "issue-552"; + const char *proj = "context-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/context-overlay"), CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/issue-552"); + cbm_mcp_server_set_session_project(srv, proj); - cbm_node_t lib_status = {0}; - lib_status.project = proj; - lib_status.label = "Function"; - lib_status.name = "status"; - lib_status.qualified_name = "issue-552.src.lib.status"; - lib_status.file_path = "src/lib/status.c"; - lib_status.start_line = 1; - lib_status.end_line = 3; - ASSERT_GT(cbm_store_upsert_node(st, &lib_status), 0); - - cbm_node_t component_status = {0}; - component_status.project = proj; - component_status.label = "Function"; - component_status.name = "status"; - component_status.qualified_name = "issue-552.src.components.status"; - component_status.file_path = "src/components/status.c"; - component_status.start_line = 1; - component_status.end_line = 3; - ASSERT_GT(cbm_store_upsert_node(st, &component_status), 0); - - cbm_store_exec(st, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"); - ASSERT_EQ(cbm_store_exec(st, - "INSERT INTO nodes_fts(rowid, name, qualified_name, label, " - "file_path) " - "SELECT id, cbm_camel_split(name), qualified_name, label, file_path " - "FROM nodes;"), + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldContextSchema", + .qualified_name = "context.overlay.OldContextSchema", + .file_path = "src/main.c"}; + cbm_node_t stable = {.project = proj, + .label = "Class", + .name = "StableContextSchema", + .qualified_name = "context.overlay.StableContextSchema", + .file_path = "src/stable.c"}; + int64_t old_fn_id = cbm_store_upsert_node(st, &old_fn); + int64_t stable_id = cbm_store_upsert_node(st, &stable); + ASSERT_GT(old_fn_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_fn_id, + .target_id = stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_class = {.project = proj, + .label = "Class", + .name = "FreshContextSchema", + .qualified_name = "context.overlay.FreshContextSchema", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t fresh_edge = {.source_qn = "context.overlay.FreshContextSchema", + .target_qn = "context.overlay.StableContextSchema", + .type = "HANDLES", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &fresh_class, + .node_count = 1, + .edges = &fresh_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), CBM_STORE_OK); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\"," - "\"file_pattern\":\"src/lib/*\",\"limit\":10}}}"); + /* Zero-match pattern: results stay empty so every label/type string in + * the response comes from _context, not from result rows. format=json + * pins the legacy _context shape. */ + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"name_pattern\":\"zzz_no_such_symbol\",\"format\":\"json\"}"); ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "search_mode: bm25")); - ASSERT_NOT_NULL(strstr(inner, "src/lib/status.c")); - ASSERT_NULL(strstr(inner, "src/components/status.c")); - - free(inner); + ASSERT_NOT_NULL(strstr(resp, "\\\"_context\\\":")); + /* Overlay view: Function rows are tombstoned, CALLS edge lost its + * source; Class and HANDLES are the active vocabulary. */ + ASSERT_NOT_NULL(strstr(resp, "Class")); + ASSERT_NOT_NULL(strstr(resp, "HANDLES")); + ASSERT_NULL(strstr(resp, "\\\"label\\\":\\\"Function\\\"")); + ASSERT_NULL(strstr(resp, "CALLS")); + ASSERT_NOT_NULL(strstr(resp, "\\\"overlay_read_view\\\":")); + ASSERT_NOT_NULL(strstr(resp, "\\\"state\\\":\\\"overlay_ready\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"count_read_model\\\":\\\"canonical_only\\\"")); free(resp); - cbm_mcp_server_free(srv); - PASS(); -} - -/* Resource discovery methods this server doesn't populate must return EMPTY - * lists, not -32601 Method-not-found: clients like Cline probe them on connect - * and surface the errors as a failed connection (#958). */ -TEST(mcp_resource_discovery_methods_return_empty_lists) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - - struct { - const char *method; - const char *want; - } cases[] = { - {"resources/list", "\"resources\":[]"}, - {"resources/templates/list", "\"resourceTemplates\":[]"}, - }; - for (int i = 0; i < 2; i++) { - char reqbuf[256]; - snprintf(reqbuf, sizeof(reqbuf), "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", - 100 + i, cases[i].method); - char *resp = cbm_mcp_server_handle(srv, reqbuf); - ASSERT_NOT_NULL(resp); - ASSERT_NULL(strstr(resp, "Method not found")); - ASSERT_NOT_NULL(strstr(resp, cases[i].want)); - free(resp); - } cbm_mcp_server_free(srv); PASS(); } -TEST(tool_query_graph_basic) { +/* cross-repo-intelligence must honor the `name` override exactly like an + * indexing call. Previously the mode derived the project from repo_path and + * silently matched a different (possibly never-indexed) project than the one + * indexed under `name`. The missing-source error must cite the overridden + * name, proving the override was used, and must not create a database. */ +TEST(tool_cross_repo_mode_honors_name_override) { cbm_mcp_server_t *srv = setup_mcp_with_data(); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":14,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"query_graph\"," - "\"arguments\":{\"query\":\"MATCH (f:Function) RETURN f.name\"}}}"); + char *resp = cbm_mcp_handle_tool( + srv, "index_repository", + "{\"repo_path\":\"/tmp/cbm-nonexistent-cross-src\",\"mode\":\"cross-repo-intelligence\"," + "\"name\":\"cross-name-override\",\"target_projects\":[\"*\"]}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + ASSERT_NOT_NULL(strstr(resp, "cross-name-override")); + ASSERT_NOT_NULL(strstr(resp, "not indexed")); free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_index_status_no_project) { +TEST(tool_unknown_tool) { cbm_mcp_server_t *srv = setup_mcp_with_data(); char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":15,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_status\",\"arguments\":{}}}"); + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"nonexistent_tool\",\"arguments\":{}}}"); ASSERT_NOT_NULL(resp); - /* Should return error or empty status */ - ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + /* MCP 2025-11-25 server/tools: unknown tools are protocol errors, not + * successful CallToolResult envelopes with isError=true. */ + ASSERT_NOT_NULL(strstr(resp, "\"id\":12")); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602")); + ASSERT_NOT_NULL(strstr(resp, "Unknown tool: nonexistent_tool")); + ASSERT_NULL(strstr(resp, "\"result\"")); + ASSERT_NULL(strstr(resp, "\"isError\"")); free(resp); cbm_mcp_server_free(srv); PASS(); } -/* Reproduce the exact-file false negative in the current Read hook: index_status - * intentionally caps each coverage category at 500 entries, so a later path is - * absent even though the authoritative index_coverage table contains it. The - * targeted coverage tool must query that table rather than scan the capped - * presentation response. */ -TEST(tool_check_index_coverage_finds_path_beyond_status_cap) { - enum { ROW_COUNT = 502 }; - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); - - const char *project = "coverage-cap-regression"; - ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/coverage-cap-regression"), CBM_STORE_OK); - cbm_mcp_server_set_project(srv, project); - - char (*paths)[64] = calloc(ROW_COUNT, sizeof(*paths)); - cbm_coverage_row_t *rows = calloc(ROW_COUNT, sizeof(*rows)); - ASSERT_NOT_NULL(paths); - ASSERT_NOT_NULL(rows); - for (int i = 0; i < ROW_COUNT; i++) { - snprintf(paths[i], sizeof(paths[i]), "src/partial-%04d.c", i); - rows[i].rel_path = paths[i]; - rows[i].kind = "parse_partial"; - rows[i].detail = i == ROW_COUNT - 1 ? "777-790" : "1-2"; - ASSERT_EQ(cbm_store_upsert_file_hash(st, project, paths[i], "fixture", i + 1, 10), - CBM_STORE_OK); - } - ASSERT_EQ(cbm_store_coverage_replace(st, project, rows, ROW_COUNT), CBM_STORE_OK); - - char *status = - cbm_mcp_handle_tool(srv, "index_status", "{\"project\":\"coverage-cap-regression\"}"); - ASSERT_NOT_NULL(status); - char *status_inner = extract_text_content(status); - ASSERT_NOT_NULL(status_inner); - ASSERT_NOT_NULL(strstr(status_inner, "\"truncated\":true")); - ASSERT_NULL(strstr(status_inner, "src/partial-0501.c")); - free(status_inner); - free(status); +TEST(tool_unknown_argument_is_actionable_execution_error) { + cbm_mcp_server_t *srv = setup_mcp_with_data(); - char *coverage = cbm_mcp_handle_tool( - srv, "check_index_coverage", - "{\"project\":\"coverage-cap-regression\",\"paths\":[\"src/partial-0501.c\"]}"); - ASSERT_NOT_NULL(coverage); - char *coverage_inner = extract_text_content(coverage); - ASSERT_NOT_NULL(coverage_inner); - ASSERT_NOT_NULL(strstr(coverage_inner, "src/partial-0501.c")); - ASSERT_NOT_NULL(strstr(coverage_inner, "\"status\":\"partial\"")); - ASSERT_NOT_NULL(strstr(coverage_inner, "777-790")); + char *direct = cbm_mcp_handle_tool( + srv, "search_code", + "{\"pattern\":\"HandleOrder\",\"repo_path\":\"/tmp/not-a-project-argument\"}"); + ASSERT_NOT_NULL(direct); + ASSERT_NOT_NULL(strstr(direct, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(direct, "repo_path")); + ASSERT_NOT_NULL(strstr(direct, "project")); + ASSERT_NOT_NULL(strstr(direct, "supported")); + free(direct); + + char *framed = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1201,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\",\"arguments\":{" + "\"pattern\":\"HandleOrder\",\"repo_path\":\"/tmp/not-a-project-argument\"}}}"); + ASSERT_NOT_NULL(framed); + /* MCP input validation is a Tool Execution Error so a model receives the + * actionable correction; malformed tools/call envelopes remain protocol errors. */ + ASSERT_NOT_NULL(strstr(framed, "\"result\"")); + ASSERT_NOT_NULL(strstr(framed, "\"isError\":true")); + ASSERT_NULL(strstr(framed, "\"code\":-32602")); + ASSERT_NOT_NULL(strstr(framed, "repo_path")); + ASSERT_NOT_NULL(strstr(framed, "project")); + free(framed); - free(coverage_inner); - free(coverage); - free(rows); - free(paths); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +TEST(tool_search_code_legacy_search_in_is_bounded_and_actionable) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); - - ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "main.go", "", 0, 0), CBM_STORE_OK); - ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "src/skip.c", "", 0, 0), CBM_STORE_OK); - cbm_coverage_row_t rows[] = { - {.rel_path = "main.go", .kind = "parse_partial", .detail = "3-4,9"}, - {.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"}, - {.rel_path = "src/skip.c", .kind = "oversized", .detail = "file exceeds cap"}, - }; - ASSERT_EQ(cbm_store_coverage_replace(st, "test-project", rows, 3), CBM_STORE_OK); - char *coverage = - cbm_mcp_handle_tool(srv, "check_index_coverage", - "{\"project\":\"test-project\"," - "\"paths\":[\"main.go\",\"generated/pkg/a.c\",\"../escape.c\"]," - "\"scopes\":[\".\"]}"); - ASSERT_NOT_NULL(coverage); - char *inner = extract_text_content(coverage); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"path\":\"main.go\"")); - ASSERT_NOT_NULL(strstr(inner, "\"status\":\"partial\"")); - ASSERT_NOT_NULL(strstr(inner, "\"start\":3")); - ASSERT_NOT_NULL(strstr(inner, "\"end\":4")); - ASSERT_NOT_NULL(strstr(inner, "\"start\":9")); - ASSERT_NOT_NULL(strstr(inner, "generated/pkg/a.c")); - ASSERT_NOT_NULL(strstr(inner, "not_indexed_dir")); - ASSERT_NOT_NULL(strstr(inner, "outside_project")); - ASSERT_NOT_NULL(strstr(inner, "src/skip.c")); - ASSERT_NOT_NULL(strstr(inner, "file exceeds cap")); - ASSERT_NOT_NULL(strstr(inner, "best_effort")); + char *response = + cbm_mcp_handle_tool(srv, "search_code", "{\"pattern\":\"needle\",\"search_in\":\"graph\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(response, "search_graph")); + ASSERT_NOT_NULL(strstr(response, "omit search_in")); + free(response); - free(inner); - free(coverage); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -TEST(tool_check_index_coverage_preserves_multiple_scope_labels) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +TEST(tool_query_graph_legacy_cypher_alias_remains_bounded) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char *coverage = cbm_mcp_handle_tool(srv, "check_index_coverage", - "{\"project\":\"test-project\"," - "\"scopes\":[\"alpha/one\",\"bravo/two\",\"charl/tri\"]}"); - ASSERT_NOT_NULL(coverage); - char *inner = extract_text_content(coverage); - ASSERT_NOT_NULL(inner); - yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); - ASSERT_NOT_NULL(doc); - yyjson_val *scopes = yyjson_obj_get(yyjson_doc_get_root(doc), "scopes"); - ASSERT_NOT_NULL(scopes); - ASSERT_TRUE(yyjson_is_arr(scopes)); - ASSERT_EQ(yyjson_arr_size(scopes), 3); + char *response = cbm_mcp_handle_tool( + srv, "query_graph", "{\"cypher\":\"MATCH (n) RETURN n LIMIT 1\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NULL(strstr(response, "unknown argument 'cypher'")); + free(response); - const char *expected[] = {"alpha/one", "bravo/two", "charl/tri"}; - for (size_t i = 0; i < 3; i++) { - yyjson_val *scope = yyjson_obj_get(yyjson_arr_get(scopes, i), "scope"); - ASSERT_NOT_NULL(scope); - ASSERT_TRUE(yyjson_is_str(scope)); - ASSERT_STR_EQ(yyjson_get_str(scope), expected[i]); - } + response = cbm_mcp_handle_tool(srv, "query_graph", "{\"label\":\"Function\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "unknown argument 'label'")); + ASSERT_NOT_NULL(strstr(response, "query")); + free(response); - yyjson_doc_free(doc); - free(inner); - free(coverage); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -static int write_coverage_meta(cbm_store_t *store, const char *generation, - const char *recording_status) { - cbm_coverage_meta_t meta = { - .generation = generation, - .index_mode = "fast", - .recorded_at = "2026-07-12T00:00:00Z", - .recording_status = recording_status, - .ignored_files_stored = 0, - .ignored_files_total = 0, - .coverage_version = 1, - .hash_records_complete = true, - }; - return cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta); -} - -TEST(tool_check_index_coverage_rejects_stale_generation) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - ASSERT_EQ(write_coverage_meta(store, "stale-generation", "complete"), CBM_STORE_OK); +TEST(tool_search_graph_basic) { + cbm_mcp_server_t *srv = setup_mcp_with_data(); - char *response = cbm_mcp_handle_tool(srv, "check_index_coverage", - "{\"project\":\"test-project\",\"paths\":[\"main.go\"]}"); - ASSERT_NOT_NULL(response); - char *inner = extract_text_content(response); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":false")); - ASSERT_NOT_NULL(strstr(inner, "\"status\":\"coverage_unavailable\"")); - ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"read_source_and_reindex\"")); + /* search_graph with no project → should work on empty store */ + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"label\":\"Function\",\"limit\":10}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + free(resp); - free(inner); - free(response); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +/* Forward declarations for helpers defined later in this file */ +static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz); +static void cleanup_snippet_dir(const char *tmp_dir); +static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path, + size_t src_sz, char *vendor_path, size_t vendor_sz); +static void cleanup_prefilter_dir(const char *tmp, const char *src_path, const char *vendor_path); +static char *extract_text_content(const char *mcp_result); + +/* callers_total/callees_total must count what the caller can enumerate: with + * include_tests=false (default) test-file rows are hidden from the table, so + * the totals must apply the same filter — a raw visited_count overstated the + * set (field-eval agent read callers_total=175 against 2 visible rows and + * distrusted the tool). */ +TEST(tool_trace_totals_respect_test_filter) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - cbm_project_t project = {0}; - ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK); - ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK); - cbm_project_free_fields(&project); - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "fixture", 0, 0), - CBM_STORE_OK); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "totproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/tot"); - char *response = cbm_mcp_handle_tool(srv, "check_index_coverage", - "{\"project\":\"test-project\",\"paths\":[\"main.go\"]}"); - ASSERT_NOT_NULL(response); - char *inner = extract_text_content(response); - ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":true")); - ASSERT_NOT_NULL(strstr(inner, "\"freshness\":\"metadata_changed\"")); - ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"read_source_and_reindex\"")); + cbm_node_t tgt = {.project = proj, + .label = "Function", + .name = "tgt", + .qualified_name = "totproj.a.tgt", + .file_path = "a.c", + .start_line = 1, + .end_line = 5}; + int64_t tid = cbm_store_upsert_node(st, &tgt); + ASSERT_GT(tid, 0); + cbm_node_t prod = {.project = proj, + .label = "Function", + .name = "prod_caller", + .qualified_name = "totproj.a.prod_caller", + .file_path = "a.c", + .start_line = 10, + .end_line = 15}; + int64_t pid = cbm_store_upsert_node(st, &prod); + ASSERT_GT(pid, 0); + cbm_node_t tst = {.project = proj, + .label = "Function", + .name = "test_caller", + .qualified_name = "totproj.t.test_caller", + .file_path = "tests/test_x.c", + .start_line = 1, + .end_line = 5}; + int64_t xid = cbm_store_upsert_node(st, &tst); + ASSERT_GT(xid, 0); + cbm_edge_t e1 = {.project = proj, .source_id = pid, .target_id = tid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e1), 0); + cbm_edge_t e2 = {.project = proj, .source_id = xid, .target_id = tid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e2), 0); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\"," + "\"function_name\":\"tgt\",\"direction\":\"inbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callers_total: 1")); /* test row filtered */ free(inner); - free(response); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); -} - -TEST(tool_check_index_coverage_surfaces_lookup_errors) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - cbm_project_t project = {0}; - ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK); - ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK); - cbm_project_free_fields(&project); - ASSERT_EQ( - cbm_store_exec(store, "ALTER TABLE index_coverage RENAME COLUMN detail TO broken_detail;"), - CBM_STORE_OK); - char *response = cbm_mcp_handle_tool( - srv, "check_index_coverage", - "{\"project\":\"test-project\",\"paths\":[\"main.go\"],\"scopes\":[\".\"]}"); - ASSERT_NOT_NULL(response); - char *inner = extract_text_content(response); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\"," + "\"function_name\":\"tgt\",\"direction\":\"inbound\",\"include_tests\":true}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + free(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"coverage_lookup\":\"error\"")); - ASSERT_NOT_NULL(strstr(inner, "\"status\":\"coverage_unavailable\"")); - ASSERT_NULL(strstr(inner, "\"status\":\"no_recorded_issue\"")); - + ASSERT_NOT_NULL(strstr(inner, "callers_total: 2")); /* both visible now */ free(inner); - free(response); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -TEST(tool_index_status_includes_git_metadata) { - /* The git context block moved behind verbose:true (lean-default contract, - * TOON round 2) — this test pins the verbose path's content; the default- - * omission guard lives in tool_lean_defaults_schema_and_status. */ - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +/* SCC condensation (get_architecture aspect "cycles"): a 3-function CALLS + * cycle A->B->C->A must be reported as one circular dependency of size 3 with + * all three members; a separate acyclic chain (D->E) must NOT appear. The + * aspect is opt-in — a default get_architecture call must NOT compute it. */ +TEST(tool_get_architecture_cycles_detects_scc) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "cycproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/cyc"); + + const char *names[5] = {"A", "B", "C", "D", "E"}; + int64_t id[5]; + for (int i = 0; i < 5; i++) { + char qn[32]; + snprintf(qn, sizeof(qn), "cycproj.m.%s", names[i]); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "m.c", + .start_line = i + 1, + .end_line = i + 2}; + id[i] = cbm_store_upsert_node(st, &n); + ASSERT_GT(id[i], 0); + } + /* cycle A->B->C->A, plus acyclic D->E */ + struct { + int f; + int t; + } e[] = {{0, 1}, {1, 2}, {2, 0}, {3, 4}}; + for (size_t i = 0; i < sizeof(e) / sizeof(e[0]); i++) { + cbm_edge_t ed = { + .project = proj, .source_id = id[e[i].f], .target_id = id[e[i].t], .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &ed), 0); + } + /* opt-in cycles aspect */ char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_status\"," - "\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"," + "\"aspects\":[\"cycles\"]}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"root_path\"")); - ASSERT_NOT_NULL(strstr(inner, "\"git\"")); - ASSERT_NOT_NULL(strstr(inner, "\"is_git\":false")); - ASSERT_NOT_NULL(strstr(inner, "\"root_exists\":true")); - + ASSERT_NOT_NULL(strstr(inner, "cycles: 1")); /* exactly one SCC of size>1 */ + ASSERT_NOT_NULL(strstr(inner, "cycproj.m.A")); + ASSERT_NOT_NULL(strstr(inner, "cycproj.m.B")); + ASSERT_NOT_NULL(strstr(inner, "cycproj.m.C")); + ASSERT_NULL(strstr(inner, "cycproj.m.D")); /* acyclic node not in any cycle */ free(inner); free(resp); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); -} - -/* ══════════════════════════════════════════════════════════════════ - * TOOL HANDLERS WITH DATA - * ══════════════════════════════════════════════════════════════════ */ - -TEST(tool_trace_call_path_not_found) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\"," - "\"arguments\":{\"function_name\":\"NonExistent\"," - "\"project\":\"nonexistent\"}}}"); + /* default call (no aspects) must NOT run the scan. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"}}}"); ASSERT_NOT_NULL(resp); - /* Should return error about project not found */ - ASSERT_NOT_NULL(strstr(resp, "not found")); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "cycles:")); + free(inner); free(resp); - cbm_mcp_server_free(srv); PASS(); } -TEST(tool_trace_missing_function_name) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\"," - "\"arguments\":{}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "required")); - free(resp); - - cbm_mcp_server_free(srv); - PASS(); -} +/* Context-bomb guard: get_code_snippet on a whole-file node (a Module/File + * span) used to read the ENTIRE file into one response — a field-eval agent + * that fell back to a Module snippet pulled ~400KB in a single call. The read + * must clip at MCP_SNIPPET_MAX_LINES and flag source_clipped, while the exact + * start/end range stays in the response for a targeted re-read. */ +TEST(tool_get_code_snippet_clips_whole_file_node) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_snipcap_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char proj_dir[512]; + snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp); + cbm_mkdir(proj_dir); + char src_path[600]; + snprintf(src_path, sizeof(src_path), "%s/big.py", proj_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + enum { BIG_LINES = 2000 }; + for (int i = 0; i < BIG_LINES; i++) { + fprintf(fp, "line_%04d = %d # padding to blow up an unclipped read\n", i, i); + } + fclose(fp); -/* Regression: two same-named definitions with equal rank must be reported - * ambiguous, not silently traced (trace_path previously took nodes[0]). */ -TEST(tool_trace_call_path_ambiguous) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "amb-proj"; + const char *proj = "test-project"; cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/amb"); - cbm_node_t a = {.project = proj, - .label = "Function", - .name = "amb", - .qualified_name = "amb-proj.a.amb", - .file_path = "a.c", - .start_line = 10, - .end_line = 20}; - cbm_node_t b = {.project = proj, - .label = "Function", - .name = "amb", - .qualified_name = "amb-proj.b.amb", - .file_path = "b.c", - .start_line = 10, - .end_line = 20}; /* equal span -> genuine tie */ - ASSERT_GT(cbm_store_upsert_node(st, &a), 0); - ASSERT_GT(cbm_store_upsert_node(st, &b), 0); + cbm_store_upsert_project(st, proj, proj_dir); + + cbm_node_t mod = {0}; + mod.project = proj; + mod.label = "Module"; + mod.name = "big"; + mod.qualified_name = "test-project.big"; + mod.file_path = "big.py"; + mod.start_line = 1; + mod.end_line = BIG_LINES; + ASSERT_GT(cbm_store_upsert_node(st, &mod), 0); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":61,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\"," - "\"arguments\":{\"function_name\":\"amb\",\"project\":\"amb-proj\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_code_snippet\",\"arguments\":{\"project\":\"test-project\"," + "\"qualified_name\":\"test-project.big\"}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "ambiguous")); - ASSERT_NOT_NULL(strstr(inner, "suggestions")); - ASSERT_NULL(strstr(inner, "\"callees\"")); + ASSERT_NOT_NULL(strstr(inner, "\"source_clipped\":true")); + /* The whole 2000-line file (~100KB) must NOT be in the response. */ + ASSERT_TRUE(strlen(inner) < 60000); + /* The last line must be absent (clipped), the first present. */ + ASSERT_NOT_NULL(strstr(inner, "line_0000")); + ASSERT_NULL(strstr(inner, "line_1999")); free(inner); free(resp); cbm_mcp_server_free(srv); + th_rmtree(tmp); PASS(); } -/* Multi-seed union hop semantics: bfs_union_same_name deduped visited nodes - * keep-FIRST-seen, so a node reached at hop 2 from the first seed kept hop 2 - * even when the second seed reaches it at hop 1. hop feeds risk_labels and - * (soon) pagination watermarks — it must be the MINIMUM across seeds, matching - * the single-BFS MIN(hop) semantics (#797). */ -TEST(tool_trace_union_records_min_hop_across_seeds) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "dualproj"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/dual"); - - /* One real definition + one body-less stub (start==end) — the #546/#650 - * shape pick_resolved_node resolves WITHOUT ambiguity while - * bfs_union_same_name still traverses both. Seed A (real def, lower id, - * traversed first) reaches tgt only via mid (hop 2); the stub seed B - * reaches tgt directly (hop 1). */ - cbm_node_t sa = {.project = proj, - .label = "Function", - .name = "dual", - .qualified_name = "dualproj.a.dual", - .file_path = "a.c", - .start_line = 1, - .end_line = 50}; - cbm_node_t sb = {.project = proj, - .label = "Function", - .name = "dual", - .qualified_name = "dualproj.b.dual", - .file_path = "b.d.ts", - .start_line = 1, - .end_line = 1}; - cbm_node_t mid = {.project = proj, - .label = "Function", - .name = "mid", - .qualified_name = "dualproj.c.mid", - .file_path = "c.c", - .start_line = 1, - .end_line = 5}; - cbm_node_t tgt = {.project = proj, - .label = "Function", - .name = "tgt", - .qualified_name = "dualproj.c.tgt", - .file_path = "c.c", - .start_line = 10, - .end_line = 15}; - int64_t ida = cbm_store_upsert_node(st, &sa); - int64_t idb = cbm_store_upsert_node(st, &sb); - int64_t idm = cbm_store_upsert_node(st, &mid); - int64_t idt = cbm_store_upsert_node(st, &tgt); - ASSERT_GT(ida, 0); - ASSERT_GT(idb, 0); - ASSERT_GT(idm, 0); - ASSERT_GT(idt, 0); - cbm_edge_t e1 = {.project = proj, .source_id = ida, .target_id = idm, .type = "CALLS"}; - cbm_edge_t e2 = {.project = proj, .source_id = idm, .target_id = idt, .type = "CALLS"}; - cbm_edge_t e3 = {.project = proj, .source_id = idb, .target_id = idt, .type = "CALLS"}; - ASSERT_GT(cbm_store_insert_edge(st, &e1), 0); - ASSERT_GT(cbm_store_insert_edge(st, &e2), 0); - ASSERT_GT(cbm_store_insert_edge(st, &e3), 0); +TEST(tool_search_graph_includes_node_properties) { + /* Node properties are OPT-IN columns in the default TOON output: the + * default row is qn/label/file/lines/degrees only, `fields` adds the + * requested property columns, and format:"json" with compact:false restores + * legacy verbose objects with non-internal properties. The setup_snippet_server + * inserts HandleRequest with a signature/return_type/is_exported blob. */ + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + /* Default TOON: compact table, no property spill. */ char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\"," - "\"arguments\":{\"function_name\":\"dual\",\"project\":\"dualproj\"," - "\"direction\":\"outbound\",\"depth\":3}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," + "\"name_pattern\":\"HandleRequest\",\"limit\":5}}}"); ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"structuredContent\":{\"text\":")); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - /* tgt is one hop from seed B — the union must record hop 1, not seed A's 2. */ - ASSERT_NOT_NULL(strstr(inner, " tgt 1")); - ASSERT_NULL(strstr(inner, " tgt 2")); + ASSERT_NOT_NULL(strstr(inner, "results[")); /* canonical TOON table header */ + ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out}:")); + ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); + ASSERT_NULL(strstr(inner, "func HandleRequest")); /* signature not spilled */ + ASSERT_NULL(strstr(inner, "is_exported")); + free(inner); + free(resp); + + /* fields:["signature"] adds the column + values. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," + "\"name_pattern\":\"HandleRequest\",\"fields\":[\"signature\"],\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out,signature}:")); + /* Comma-delimited TOON preserves spaces inside an unquoted field. */ + ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); + free(inner); + free(resp); + + /* format:"json", compact:false keeps useful legacy metadata intact. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," + "\"name_pattern\":\"HandleRequest\",\"format\":\"json\",\"compact\":false," + "\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"signature\"")); + ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); + ASSERT_NOT_NULL(strstr(inner, "is_exported")); + const char *scan = inner; + int source_keys = 0; + while ((scan = strstr(scan, "\"source\"")) != NULL) { + source_keys++; + scan += strlen("\"source\""); + } + ASSERT_EQ(source_keys, 1); free(inner); free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -/* Exactly-once trace pagination: 12 callees paged at limit=5 must yield - * 5+5+2 rows with every callee appearing on exactly one page, exact totals - * on every page, and a final page without a cursor. Stale and mismatched - * cursors must fail with teaching errors, never silently restart. */ -TEST(tool_trace_pagination_exactly_once) { +TEST(tool_search_graph_warns_on_stale_pagerank_view) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "pageproj"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/page"); - - cbm_node_t hub = {.project = proj, - .label = "Function", - .name = "hub", - .qualified_name = "pageproj.h.hub", - .file_path = "h.c", - .start_line = 1, - .end_line = 9}; - int64_t hid = cbm_store_upsert_node(st, &hub); - ASSERT_GT(hid, 0); - enum { CALLEES = 12 }; - for (int i = 0; i < CALLEES; i++) { - char nm[16]; - char qn[48]; - snprintf(nm, sizeof(nm), "c%02d", i); - snprintf(qn, sizeof(qn), "pageproj.m.c%02d", i); - cbm_node_t n = {.project = proj, - .label = "Function", - .name = nm, - .qualified_name = qn, - .file_path = "m.c", - .start_line = 1, - .end_line = 3}; - int64_t nid = cbm_store_upsert_node(st, &n); - ASSERT_GT(nid, 0); - cbm_edge_t e = {.project = proj, .source_id = hid, .target_id = nid, .type = "CALLS"}; - ASSERT_GT(cbm_store_insert_edge(st, &e), 0); - } + ASSERT_NOT_NULL(st); + ASSERT_EQ(cbm_store_upsert_project(st, "test", "/tmp/test"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, "test"); - char pages[3][4096]; - char tok[192] = ""; - int npages = 0; - for (; npages < 3; npages++) { - char req[640]; - if (tok[0]) { - snprintf(req, sizeof(req), - "{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," - "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5," - "\"cursor\":\"%s\"}}}", - tok); - } else { - snprintf(req, sizeof(req), - "{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," - "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5}}}"); - } - char *resp = cbm_mcp_server_handle(srv, req); - ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - free(resp); - ASSERT_NOT_NULL(inner); - snprintf(pages[npages], sizeof(pages[npages]), "%s", inner); - ASSERT_NOT_NULL(strstr(inner, "callees_total: 12")); /* exact total, every page */ - const char *nx = strstr(inner, "next: "); - if (nx) { - const char *e = strchr(nx + 6, '\n'); - size_t tl = e ? (size_t)(e - (nx + 6)) : strlen(nx + 6); - ASSERT_TRUE(tl < sizeof(tok)); - memcpy(tok, nx + 6, tl); - tok[tl] = '\0'; - } else { - tok[0] = '\0'; - } - free(inner); - if (!tok[0]) { - npages++; - break; - } - } - ASSERT_EQ(npages, 3); /* 5 + 5 + 2 */ - /* Exactly-once: every callee appears on exactly ONE page. */ - for (int i = 0; i < CALLEES; i++) { - char qn[48]; - snprintf(qn, sizeof(qn), " c%02d 1\n", i); - int seen = 0; - for (int p = 0; p < 3; p++) { - if (strstr(pages[p], qn)) { - seen++; - } - } - ASSERT_EQ(seen, 1); - } - /* Final page carries no cursor. */ - ASSERT_NULL(strstr(pages[2], "next: ")); + cbm_node_t node = {.project = "test", + .label = "Function", + .name = "Handle", + .qualified_name = "test.Handle", + .file_path = "handle.c"}; + int64_t id = cbm_store_upsert_node(st, &node); + ASSERT_TRUE(id > 0); + char rank_sql[256]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('test',%lld,0.9,'2026-06-30T00:00:00Z')", + (long long)id); + ASSERT_EQ(cbm_store_exec(st, rank_sql), CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_derived_view_state(st, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); - /* Params mismatch: replay a page-2-era cursor with a different depth. */ - const char *nx1 = strstr(pages[0], "next: "); - ASSERT_NOT_NULL(nx1); - char tok1[192]; - const char *e1 = strchr(nx1 + 6, '\n'); - size_t tl1 = e1 ? (size_t)(e1 - (nx1 + 6)) : strlen(nx1 + 6); - memcpy(tok1, nx1 + 6, tl1); - tok1[tl1] = '\0'; - char req2[640]; - snprintf(req2, sizeof(req2), - "{\"jsonrpc\":\"2.0\",\"id\":81,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," - "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,\"depth\":2," - "\"cursor\":\"%s\"}}}", - tok1); - char *resp = cbm_mcp_server_handle(srv, req2); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"test\",\"label\":\"Function\",\"limit\":5," + "\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); - free(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "cursor_params_mismatch")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "pagerank derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_PAGERANK)); + ASSERT_NULL(strstr(inner, "\"pagerank\":")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} - /* Stale: an index run (upsert_project bumps the generation) invalidates - * outstanding cursors with a loud, actionable error. */ - cbm_store_upsert_project(st, proj, "/tmp/page"); - snprintf(req2, sizeof(req2), - "{\"jsonrpc\":\"2.0\",\"id\":82,\"method\":\"tools/call\",\"params\":{" - "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," - "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5," - "\"cursor\":\"%s\"}}}", - tok1); - resp = cbm_mcp_server_handle(srv, req2); +TEST(tool_search_graph_warns_on_stale_route_view) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "search-route-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/search-route-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t route = {.project = proj, + .label = "Route", + .name = "/api/status", + .qualified_name = "__route__/api/status", + .file_path = "src/status.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &route), 0); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"search-route-stale\",\"label\":\"Route\"," + "\"limit\":5,\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - inner = extract_text_content(resp); - free(resp); + char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "stale_cursor")); - free(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "routes derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_ROUTES)); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* Regression: when same-named nodes differ in rank, trace must pick the real - * definition (callable, larger body) — NOT nodes[0]. The Module is inserted - * first; if trace took nodes[0] the outbound trace would be empty. */ -TEST(tool_trace_call_path_prefers_definition) { +TEST(tool_search_graph_reports_dirty_metadata_without_hiding_canonical_rows) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "pref-proj"; + ASSERT_NOT_NULL(st); + const char *proj = "dirty-metadata"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/dirty-metadata"), CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/pref"); - /* nodes[0]: the WRONG match (a Module, tiny span), inserted first. */ - cbm_node_t wrong = {.project = proj, - .label = "Module", - .name = "dup", - .qualified_name = "pref-proj.dup", - .file_path = "dup.x", - .start_line = 1, - .end_line = 1}; - /* the real definition: a Function with a body. */ - cbm_node_t def = {.project = proj, - .label = "Function", - .name = "dup", - .qualified_name = "pref-proj.src.dup", - .file_path = "src/dup.c", - .start_line = 10, - .end_line = 50}; - cbm_node_t callee = {.project = proj, - .label = "Function", - .name = "callee", - .qualified_name = "pref-proj.src.callee", - .file_path = "src/dup.c", - .start_line = 60, - .end_line = 70}; - ASSERT_GT(cbm_store_upsert_node(st, &wrong), 0); - int64_t id_def = cbm_store_upsert_node(st, &def); - int64_t id_callee = cbm_store_upsert_node(st, &callee); - ASSERT_GT(id_def, 0); - ASSERT_GT(id_callee, 0); - cbm_edge_t e = {.project = proj, .source_id = id_def, .target_id = id_callee, .type = "CALLS"}; - cbm_store_insert_edge(st, &e); + + cbm_node_t node = {.project = proj, + .label = "Function", + .name = "StillVisible", + .qualified_name = "dirty.StillVisible", + .file_path = "src/dirty.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "src/dirty.c", + .observed_hash = "dirty-hash", + .observed_generation = 7, + .source = CBM_STORE_DIRTY_SOURCE_GIT_STATUS, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dup\"," - "\"project\":\"pref-proj\",\"direction\":\"outbound\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":145,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"dirty-metadata\",\"label\":\"Function\"," + "\"name_pattern\":\"StillVisible\",\"limit\":5,\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NULL(strstr(inner, "ambiguous")); - /* picked the Function definition -> its outbound CALLS edge to "callee" shows */ - ASSERT_NOT_NULL(strstr(inner, "callee")); + ASSERT_NOT_NULL(strstr(inner, "StillVisible")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "project has dirty files")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + free(inner); free(resp); cbm_mcp_server_free(srv); PASS(); } -/* CONTRACT PIN for the closed strategy vocabulary published by - * trace_path(include_evidence:true). - * - * The indexer records ~20 internal strategy names on CALLS edges and the set - * grows with every language added. We publish a CLASS, not the raw name, so a - * resolver rename cannot silently change a user-visible field. This test is - * what keeps that promise honest: every strategy production can emit must land - * in a known class. Adding lsp_foo_dispatch passes automatically; introducing a - * genuinely new KIND of resolution fails HERE and forces a deliberate decision - * about the public contract instead of leaking an internal name. */ -TEST(trace_evidence_strategy_class_vocabulary_is_closed) { - /* Every strategy string assigned anywhere in src/ + internal/ as of this - * commit, plus the two literals pass_calls.c writes directly. */ - static const char *const lsp[] = {"lsp_direct", "lsp_base_dispatch", - "lsp_embed_dispatch", "lsp_implicit_this", - "lsp_inherited_dispatch", "lsp_method_dispatch", - "lsp_proc_macro", "lsp_smart_ptr_dispatch", - "lsp_strategy_cross_file", "lsp_trait_dispatch", - "lsp_type_dispatch", "lsp_virtual_dispatch"}; - for (size_t i = 0; i < sizeof(lsp) / sizeof(lsp[0]); i++) { - const char *cls = cbm_mcp_edge_strategy_class(lsp[i]); - ASSERT_NOT_NULL(cls); - ASSERT_STR_EQ(cls, "lsp"); - } - static const char *const lang[] = {"php_self_static", "php_static_resolved", - "perl_method_static", "perl_method_typed"}; - for (size_t i = 0; i < sizeof(lang) / sizeof(lang[0]); i++) { - const char *cls = cbm_mcp_edge_strategy_class(lang[i]); - ASSERT_NOT_NULL(cls); - ASSERT_STR_EQ(cls, "language_rule"); - } - static const char *const heur[] = {"callee_suffix", "field_type_hint", "service_pattern", - "fastapi_depends"}; - for (size_t i = 0; i < sizeof(heur) / sizeof(heur[0]); i++) { - const char *cls = cbm_mcp_edge_strategy_class(heur[i]); - ASSERT_NOT_NULL(cls); - ASSERT_STR_EQ(cls, "heuristic"); - } - /* A failed LSP resolution is reported as unresolved, not as "lsp" — the - * caller's question is whether the edge is trustworthy, and "we tried LSP - * and it did not resolve" answers no. */ - ASSERT_STR_EQ(cbm_mcp_edge_strategy_class("lsp_unresolved"), "unresolved"); - ASSERT_STR_EQ(cbm_mcp_edge_strategy_class("unknown"), "unresolved"); - /* Only a NULL/empty strategy is unclassified — an unmapped non-empty value - * must never silently disappear from the output. */ - ASSERT_NULL(cbm_mcp_edge_strategy_class(NULL)); - ASSERT_NULL(cbm_mcp_edge_strategy_class("")); - ASSERT_STR_EQ(cbm_mcp_edge_strategy_class("some_future_resolver"), "heuristic"); - PASS(); -} - -/* Distilled from #559 (@vvenegasv). The indexer already records - * {strategy, confidence} on every CALLS edge (pass_calls.c:355) and the store - * reads it back, but no tool ever surfaced it — an agent could see THAT A->B - * exists, never HOW it was resolved. - * - * Binds two things at once: the evidence columns appear only when asked for - * (default stays lean), and the published value is the CLASS, not the raw - * internal strategy name. Fails without the production change in both - * directions — no columns at all before, and "lsp_trait_dispatch" would leak - * verbatim if the classifier were bypassed. */ -TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped) { +TEST(tool_search_graph_uses_overlay_active_node_rows) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "ev-proj"; + ASSERT_NOT_NULL(st); + const char *proj = "search-overlay-active"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/search-overlay-active"), CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/ev"); - cbm_node_t caller = {.project = proj, - .label = "Function", - .name = "caller", - .qualified_name = "ev-proj.src.caller", - .file_path = "src/a.c", - .start_line = 1, - .end_line = 5}; - cbm_node_t callee = {.project = proj, + + cbm_node_t old_main = {.project = proj, + .label = "OldFunction", + .name = "old_main", + .qualified_name = "search.overlay.old_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_node_t stable = {.project = proj, .label = "Function", - .name = "target", - .qualified_name = "ev-proj.src.target", - .file_path = "src/a.c", - .start_line = 10, - .end_line = 20}; - int64_t id_caller = cbm_store_upsert_node(st, &caller); - int64_t id_callee = cbm_store_upsert_node(st, &callee); - ASSERT_GT(id_caller, 0); - ASSERT_GT(id_callee, 0); - /* Exactly the shape pass_calls.c:355 writes in production. */ - cbm_edge_t e = {.project = proj, - .source_id = id_caller, - .target_id = id_callee, - .type = "CALLS", - .properties_json = "{\"callee\":\"target\",\"confidence\":0.95," - "\"strategy\":\"lsp_trait_dispatch\",\"candidates\":1}"}; - ASSERT_GT(cbm_store_insert_edge(st, &e), 0); + .name = "stable", + .qualified_name = "search.overlay.stable", + .file_path = "stable.c", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_main), 0); + ASSERT_GT(cbm_store_upsert_node(st, &stable), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t newer_main = {.project = proj, + .label = "NewFunction", + .name = "newer_main", + .qualified_name = "search.overlay.newer_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "main.c", + .generation = 1, + .nodes = &newer_main, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - /* Default: lean. No evidence columns, no strategy anywhere. */ - char *plain = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"caller\"," - "\"project\":\"ev-proj\",\"direction\":\"outbound\"}}}"); - ASSERT_NOT_NULL(plain); - char *plain_txt = extract_text_content(plain); - ASSERT_NOT_NULL(plain_txt); - ASSERT_NOT_NULL(strstr(plain_txt, "target")); /* positive control: the hop IS there */ - ASSERT_NULL(strstr(plain_txt, "lsp")); - ASSERT_NULL(strstr(plain_txt, "0.95")); - free(plain_txt); - free(plain); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":147,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"search-overlay-active\"," + "\"pattern\":\"main|stable\",\"sort_by\":\"name\"," + "\"limit\":5,\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "newer_main")); + ASSERT_NOT_NULL(strstr(inner, "stable")); + ASSERT_NULL(strstr(inner, "old_main")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_file_tombstones\":1")); + ASSERT_NOT_NULL(strstr(inner, "graph mode used overlay active node rows")); - /* Opted in: the class and the confidence appear, the raw name does not. */ - char *ev = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"caller\"," - "\"project\":\"ev-proj\",\"direction\":\"outbound\",\"include_evidence\":true}}}"); - ASSERT_NOT_NULL(ev); - char *ev_txt = extract_text_content(ev); - ASSERT_NOT_NULL(ev_txt); - ASSERT_NOT_NULL(strstr(ev_txt, "target")); - ASSERT_NOT_NULL(strstr(ev_txt, "lsp")); - ASSERT_NOT_NULL(strstr(ev_txt, "0.95")); - /* The internal resolver name must NOT reach the client. */ - ASSERT_NULL(strstr(ev_txt, "lsp_trait_dispatch")); - free(ev_txt); - free(ev); + free(inner); + free(resp); + + /* Default TOON summary must use the same active-node authority as full + * JSON search. Distinct labels make a canonical-row regression observable + * even though summary mode intentionally suppresses node names. */ + resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":148,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"search-overlay-active\"," + "\"pattern\":\"main|stable\",\"mode\":\"summary\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "by_label")); + ASSERT_NOT_NULL(strstr(inner, "\n NewFunction,1\n")); + ASSERT_NOT_NULL(strstr(inner, "\n Function,1\n")); + ASSERT_NULL(strstr(inner, "OldFunction")); + ASSERT_NOT_NULL(strstr(inner, "results_suppressed: true")); + + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* Reproduce-first (#887): the client-supplied `depth` on trace_call_path must be - * clamped to the MCP ceiling (cbm_mcp_max_depth(), default 15). On origin/main - * an MCP_MAX_DEPTH=15 constant was defined but never applied — `depth` flowed - * straight into bfs_union_same_name, so an unbounded value drives the shared - * cbm_store_bfs to arbitrary depth. Over an 18-node call chain, depth=1000 - * reaches n16/n17 (RED); with the clamp the walk stops at hop 15, so n15 is - * reached but n16 is not (GREEN). Quoted tokens ("n15"/"n16") match only the - * node-name field, never the qualified_name (preceded by '.'), so the boundary - * check is exact. */ -TEST(tool_trace_call_path_depth_clamped) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "depth-proj"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/depth"); - - /* Linear call chain n00 -CALLS-> n01 -> ... -> n17 (18 nodes). */ - int64_t ids[18]; - for (int i = 0; i < 18; i++) { - char name[8]; - char qn[32]; - snprintf(name, sizeof(name), "n%02d", i); - snprintf(qn, sizeof(qn), "depth-proj.n%02d", i); - cbm_node_t n = {.project = proj, - .label = "Function", - .name = name, - .qualified_name = qn, - .file_path = "chain.c", - .start_line = 1, - .end_line = 2}; - ids[i] = cbm_store_upsert_node(st, &n); +typedef struct { + bool saw_active_node_candidates; + bool saw_direct_canonical_count; +} snippet_overlay_sql_trace_t; + +static int snippet_overlay_sql_trace(unsigned trace_type, void *context, void *statement, + void *sql_text) { + (void)statement; + if (trace_type != SQLITE_TRACE_STMT || !context || !sql_text) { + return 0; } - for (int i = 0; i < 17; i++) { - cbm_edge_t e = { - .project = proj, .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; - cbm_store_insert_edge(st, &e); + snippet_overlay_sql_trace_t *trace = context; + const char *sql = sql_text; + if (strstr(sql, "active_node_candidates")) { + trace->saw_active_node_candidates = true; + } + if (strstr(sql, "SELECT COUNT(*) FROM nodes WHERE project")) { + trace->saw_direct_canonical_count = true; } + return 0; +} - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"n00\"," - "\"project\":\"depth-proj\",\"direction\":\"outbound\",\"depth\":1000}}}"); +TEST(tool_get_code_clean_path_skips_overlay_summary_and_warns_when_dirty) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + sqlite3 *db = cbm_store_get_db(st); + ASSERT_NOT_NULL(db); + + /* Consume the required automatic first-response architecture context + * before isolating the steady-state snippet SQL contract. */ + char *resp = cbm_mcp_handle_tool( + srv, "get_code", + "{\"project\":\"test-project\"," + "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"}"); + ASSERT_NOT_NULL(resp); + free(resp); + + snippet_overlay_sql_trace_t trace = {0}; + ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_STMT, snippet_overlay_sql_trace, &trace), + SQLITE_OK); + resp = cbm_mcp_handle_tool( + srv, "get_code", + "{\"project\":\"test-project\"," + "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "func HandleRequest() error")); + ASSERT_FALSE(trace.saw_active_node_candidates); + ASSERT_FALSE(trace.saw_direct_canonical_count); + ASSERT_EQ(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK); + free(inner); + free(resp); - /* Reached within the ceiling (proves the traversal ran) but clamped at 15. - * TOON rows carry bare QNs, so match the names unquoted. */ - ASSERT_NOT_NULL(strstr(inner, "n15")); - ASSERT_NULL(strstr(inner, "n16")); + cbm_dirty_file_state_t dirty = { + .project = "test-project", + .rel_path = "main.go", + .observed_hash = "live-edit-without-ready-overlay", + .observed_generation = 2, + .source = CBM_STORE_DIRTY_SOURCE_GIT_STATUS, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "main.go", + "canonical-main-hash", 1, 1), + CBM_STORE_OK); + cbm_coverage_row_t coverage = { + .rel_path = "main.go", .kind = "parse_partial", .detail = "3-5"}; + ASSERT_EQ(cbm_store_coverage_replace(st, "test-project", &coverage, 1), CBM_STORE_OK); + + resp = cbm_mcp_handle_tool( + srv, "get_code_snippet", + "{\"project\":\"test-project\"," + "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + ASSERT_NOT_NULL(strstr(inner, "canonical node spans")); + ASSERT_NOT_NULL(strstr(inner, "until overlay extraction or reindex completes")); + ASSERT_NOT_NULL(strstr(inner, "returned source bytes")); + ASSERT_NOT_NULL(strstr(inner, "dirty canonical span can lag live edits")); + ASSERT_NULL(strstr(inner, "source above is ground truth")); free(inner); free(resp); + cleanup_snippet_dir(tmp); cbm_mcp_server_free(srv); PASS(); } -/* Reproduce-first (#650, distilled): two GENUINELY-DIFFERENT same-named functions - * whose bodies differ in length score differently, so the old exact-tie check did - * not flag them ambiguous — and bfs_union_same_name (#546) then merged the caller - * sets of both into one confidently-conflated answer (the mirror of #546's under- - * report). The fix: 2+ real callable defs => ambiguous (disambiguate), never union - * distinct symbols. RED before the pick_resolved_node real_def_count rule (response - * merged callerA+callerB), GREEN after (response is ambiguous, no "callers"). */ -TEST(tool_trace_call_path_distinct_defs_not_over_unioned) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_get_code_uses_overlay_active_symbol_span) { + enum { BASE_GENERATION = 1 }; + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "ou-proj"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/ou"); - /* two unrelated real definitions of "dupreal", DIFFERENT body spans */ - cbm_node_t da = {.project = proj, - .label = "Function", - .name = "dupreal", - .qualified_name = "ou-proj.a.dupreal", - .file_path = "a.c", - .start_line = 10, - .end_line = 20}; /* span 10 */ - cbm_node_t db = {.project = proj, - .label = "Function", - .name = "dupreal", - .qualified_name = "ou-proj.b.dupreal", - .file_path = "b.c", - .start_line = 10, - .end_line = 40}; /* span 30 (no tie) */ - cbm_node_t ca = {.project = proj, - .label = "Function", - .name = "callerA", - .qualified_name = "ou-proj.a.callerA", - .file_path = "a.c", - .start_line = 30, - .end_line = 40}; - cbm_node_t cb = {.project = proj, - .label = "Function", - .name = "callerB", - .qualified_name = "ou-proj.b.callerB", - .file_path = "b.c", - .start_line = 50, - .end_line = 60}; - int64_t id_da = cbm_store_upsert_node(st, &da); - int64_t id_db = cbm_store_upsert_node(st, &db); - int64_t id_ca = cbm_store_upsert_node(st, &ca); - int64_t id_cb = cbm_store_upsert_node(st, &cb); - ASSERT_GT(id_da, 0); - ASSERT_GT(id_db, 0); - ASSERT_GT(id_ca, 0); - ASSERT_GT(id_cb, 0); - cbm_edge_t ea = {.project = proj, .source_id = id_ca, .target_id = id_da, .type = "CALLS"}; - cbm_edge_t eb = {.project = proj, .source_id = id_cb, .target_id = id_db, .type = "CALLS"}; - cbm_store_insert_edge(st, &ea); - cbm_store_insert_edge(st, &eb); + ASSERT_NOT_NULL(st); - char *resp = cbm_mcp_server_handle( - srv, - "{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dupreal\"," - "\"project\":\"ou-proj\",\"direction\":\"inbound\"}}}"); - ASSERT_NOT_NULL(resp); - char *inner = extract_text_content(resp); - ASSERT_NOT_NULL(inner); - /* distinct symbols must be disambiguated, not merged into one caller set */ - ASSERT_NOT_NULL(strstr(inner, "ambiguous")); - ASSERT_NOT_NULL(strstr(inner, "suggestions")); - ASSERT_NULL(strstr(inner, "\"callers\"")); - free(inner); - free(resp); + char src_path[512]; + int n = snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(src_path)); + ASSERT_EQ(th_write_file(src_path, + "package main\n" + "\n" + "// canonical span no longer names the function\n" + "\n" + "// shifted by a live edit\n" + "func HandleRequest() error {\n" + "\treturn nil\n" + "}\n"), + 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, "test-project", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_nodes[] = { + {.project = "test-project", + .label = "Function", + .name = "HandleRequest", + .qualified_name = "test-project.cmd.server.main.HandleRequest", + .file_path = "main.go", + .start_line = 6, + .end_line = 8, + .properties_json = "{\"signature\":\"func HandleRequest() error\"}"}, + {.project = "test-project", + .label = "Function", + .name = "ProcessOrder", + .qualified_name = "test-project.cmd.server.main.ProcessOrder", + .file_path = "main.go", + .start_line = 0, + .end_line = 0, + .properties_json = "{}"}, + {.project = "test-project", + .label = "Function", + .name = "Run", + .qualified_name = "test-project.cmd.server.Run", + .file_path = "main.go", + .start_line = 0, + .end_line = 0, + .properties_json = "{}"}, + {.project = "test-project", + .label = "Function", + .name = "Caller", + .qualified_name = "test-project.cmd.server.Caller", + .file_path = "main.go", + .start_line = 0, + .end_line = 0, + .properties_json = "{}"}}; + cbm_store_delta_edge_t fresh_edges[] = { + {.source_qn = "test-project.cmd.server.main.HandleRequest", + .target_qn = "test-project.cmd.server.main.ProcessOrder", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}, + {.source_qn = "test-project.cmd.server.main.HandleRequest", + .target_qn = "test-project.cmd.server.Run", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}, + {.source_qn = "test-project.cmd.server.Caller", + .target_qn = "test-project.cmd.server.main.HandleRequest", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}}; + cbm_store_file_delta_t delta = {.project = "test-project", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = fresh_nodes, + .node_count = CBM_SZ_4, + .edges = fresh_edges, + .edge_count = CBM_SZ_3}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + const char *tool_names[] = {"get_code", "get_code_snippet"}; + const char *qualified_names[] = {"test-project.cmd.server.main.HandleRequest", + "main.HandleRequest"}; + for (size_t i = 0; i < sizeof(tool_names) / sizeof(tool_names[0]); i++) { + char args[CBM_SZ_512]; + n = snprintf(args, sizeof(args), + "{\"project\":\"test-project\"," + "\"qualified_name\":\"%s\"," + "\"include_neighbors\":true," + "\"mode\":\"full\"}", + qualified_names[i]); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(args)); + char *resp = cbm_mcp_handle_tool(srv, tool_names[i], args); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"start_line\":6")); + ASSERT_NOT_NULL(strstr(inner, "\"end_line\":8")); + ASSERT_NOT_NULL(strstr(inner, "func HandleRequest() error")); + ASSERT_NULL(strstr(inner, "canonical span no longer names the function")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "\"callers\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"callees\":2")); + ASSERT_NOT_NULL(strstr(inner, "\"caller_names\"")); + ASSERT_NOT_NULL(strstr(inner, "Caller")); + ASSERT_NOT_NULL(strstr(inner, "\"callee_names\"")); + ASSERT_NOT_NULL(strstr(inner, "ProcessOrder")); + ASSERT_NOT_NULL(strstr(inner, "Run")); + free(inner); + free(resp); + } + + cleanup_snippet_dir(tmp); cbm_mcp_server_free(srv); PASS(); } -/* Guard that the ambiguity gate does NOT regress the #546 fix: a real .ts - * implementation plus a body-less ambient .d.ts stub is ONE logical symbol - * (one real callable def + a fragment), so it must stay non-ambiguous and the - * caller sets from both nodes must be unioned. */ -TEST(tool_trace_call_path_dts_stub_unions_with_impl) { +TEST(tool_search_graph_uses_overlay_active_relationship_rows) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "dts-proj"; + ASSERT_NOT_NULL(st); + const char *proj = "search-overlay-relationship"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/search-overlay-relationship"), + CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/dts"); - cbm_node_t impl = {.project = proj, - .label = "Function", - .name = "sym546", - .qualified_name = "dts-proj.impl.sym546", - .file_path = "src/sym.ts", - .start_line = 10, - .end_line = 30}; /* real body */ - cbm_node_t stub = {.project = proj, - .label = "Function", - .name = "sym546", - .qualified_name = "dts-proj.stub.sym546", - .file_path = "types/sym.d.ts", - .start_line = 5, - .end_line = 5}; /* body-less ambient decl */ - cbm_node_t crel = {.project = proj, - .label = "Function", - .name = "callerRel", - .qualified_name = "dts-proj.callerRel", - .file_path = "src/rel.ts", - .start_line = 1, - .end_line = 8}; - cbm_node_t cali = {.project = proj, - .label = "Function", - .name = "callerAlias", - .qualified_name = "dts-proj.callerAlias", - .file_path = "src/ali.ts", - .start_line = 1, - .end_line = 8}; - int64_t id_impl = cbm_store_upsert_node(st, &impl); - int64_t id_stub = cbm_store_upsert_node(st, &stub); - int64_t id_crel = cbm_store_upsert_node(st, &crel); - int64_t id_cali = cbm_store_upsert_node(st, &cali); - ASSERT_GT(id_impl, 0); - ASSERT_GT(id_stub, 0); - ASSERT_GT(id_crel, 0); - ASSERT_GT(id_cali, 0); - /* callers split by import style: relative -> impl, path-alias -> stub */ - cbm_edge_t er = {.project = proj, .source_id = id_crel, .target_id = id_impl, .type = "CALLS"}; - cbm_edge_t el = {.project = proj, .source_id = id_cali, .target_id = id_stub, .type = "CALLS"}; - cbm_store_insert_edge(st, &er); - cbm_store_insert_edge(st, &el); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":64,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"sym546\"," - "\"project\":\"dts-proj\",\"direction\":\"inbound\"}}}"); + cbm_node_t old_main = {.project = proj, + .label = "Function", + .name = "old_main", + .qualified_name = "search.relationship.old_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_node_t stable = {.project = proj, + .label = "Function", + .name = "stable", + .qualified_name = "search.relationship.stable", + .file_path = "stable.c", + .properties_json = "{}"}; + int64_t old_main_id = cbm_store_upsert_node(st, &old_main); + int64_t stable_id = cbm_store_upsert_node(st, &stable); + ASSERT_GT(old_main_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_main_id, + .target_id = stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_main = {.project = proj, + .label = "Function", + .name = "new_main", + .qualified_name = "search.relationship.new_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "search.relationship.new_main", + .target_qn = "search.relationship.stable", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "main.c", + .generation = 1, + .nodes = &new_main, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":148,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"search-overlay-relationship\"," + "\"relationship\":\"CALLS\",\"sort_by\":\"name\"," + "\"include_connected\":true,\"limit\":5}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NULL(strstr(inner, "ambiguous")); - /* union across impl + stub: BOTH callers appear (this is the #546 fix) */ - ASSERT_NOT_NULL(strstr(inner, "callerRel")); - ASSERT_NOT_NULL(strstr(inner, "callerAlias")); + ASSERT_NOT_NULL(strstr(inner, "new_main")); + ASSERT_NOT_NULL(strstr(inner, "stable")); + ASSERT_NULL(strstr(inner, "old_main")); + ASSERT_NOT_NULL(strstr(inner, "\"connected_names\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "overlay active node and relationship rows")); + ASSERT_NOT_NULL(strstr(inner, "include_connected uses active one-hop names")); + + free(inner); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":149,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"project\":\"search-overlay-relationship\"," + "\"qualified_name\":\"search.relationship.new_main\"," + "\"direction\":\"outbound\",\"depth\":1}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "stable")); + ASSERT_NULL(strstr(inner, "old_main")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "trace_path used overlay active node and relationship rows")); + + free(inner); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":150,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"project\":\"search-overlay-relationship\"," + "\"function_name\":\"new_main\"," + "\"direction\":\"outbound\",\"depth\":1}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "stable")); + ASSERT_NULL(strstr(inner, "old_main")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "trace_path used overlay active node and relationship rows")); + + free(inner); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":151,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"project\":\"search-overlay-relationship\"," + "\"qualified_name\":\"search.relationship.missing\"," + "\"direction\":\"outbound\",\"depth\":1}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "function not found for qualified_name")); + ASSERT_NULL(strstr(inner, "\"read_model\":\"overlay_active_graph\"")); + free(inner); free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_delete_project_not_found) { +TEST(tool_search_graph_uses_overlay_active_inbound_relationship_rows) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "search-overlay-inbound"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/search-overlay-inbound"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_target = {.project = proj, + .label = "Function", + .name = "old_target", + .qualified_name = "search.inbound.old_target", + .file_path = "target.c", + .properties_json = "{}"}; + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "caller", + .qualified_name = "search.inbound.caller", + .file_path = "caller.c", + .properties_json = "{}"}; + int64_t old_target_id = cbm_store_upsert_node(st, &old_target); + int64_t caller_id = cbm_store_upsert_node(st, &caller); + ASSERT_GT(old_target_id, 0); + ASSERT_GT(caller_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = caller_id, + .target_id = old_target_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_target = {.project = proj, + .label = "Function", + .name = "new_target", + .qualified_name = "search.inbound.new_target", + .file_path = "target.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t preserved_inbound = { + .source_qn = "search.inbound.caller", + .target_qn = "search.inbound.new_target", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "target.c", + .generation = 1, + .nodes = &new_target, + .node_count = 1, + .edges = &preserved_inbound, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":22,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"delete_project\"," - "\"arguments\":{\"project\":\"nonexistent\"}}}"); + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":152,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"search-overlay-inbound\"," + "\"relationship\":\"CALLS\",\"sort_by\":\"name\"," + "\"include_connected\":true,\"limit\":5}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "not_found")); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "caller")); + ASSERT_NOT_NULL(strstr(inner, "new_target")); + ASSERT_NULL(strstr(inner, "old_target")); + ASSERT_NOT_NULL(strstr(inner, "\"connected_names\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_graph\"")); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_delete_project_mutation_guard_blocks_then_releases) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-guard-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); - } +static bool mcp_test_upsert_fts_node(cbm_store_t *st, const char *project, const char *label, + const char *name, const char *qualified_name, + const char *file_path) { + cbm_node_t node = {0}; + node.project = project; + node.label = label; + node.name = name; + node.qualified_name = qualified_name; + node.file_path = file_path; + node.start_line = 1; + node.end_line = 3; + return cbm_store_upsert_node(st, &node) > 0; +} - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); +static int mcp_test_rebuild_nodes_fts(cbm_store_t *st) { + return cbm_store_rebuild_nodes_fts(st); +} - const char *project = "guard-delete-project"; - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - cbm_store_t *setup = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(setup); - ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/guard-delete-project"), CBM_STORE_OK); - cbm_store_close(setup); - ASSERT_TRUE(cbm_file_exists(db_path)); +static bool mcp_test_install_empty_vector_tables(cbm_store_t *st) { + sqlite3 *db = cbm_store_get_db(st); + return db && + sqlite3_exec(db, + "CREATE TABLE node_vectors (" + "node_id INTEGER PRIMARY KEY, project TEXT NOT NULL, vector BLOB NOT NULL);" + "CREATE TABLE token_vectors (" + "id INTEGER PRIMARY KEY, project TEXT NOT NULL, token TEXT NOT NULL," + "vector BLOB NOT NULL, idf INTEGER NOT NULL);", + NULL, NULL, NULL) == SQLITE_OK; +} + +static bool mcp_test_install_malformed_token_vector(cbm_store_t *st, const char *project, + const char *token) { + sqlite3 *db = cbm_store_get_db(st); + sqlite3_stmt *stmt = NULL; + if (!db || sqlite3_exec(db, + "CREATE TABLE token_vectors (" + "id INTEGER PRIMARY KEY, project TEXT NOT NULL, token TEXT NOT NULL," + "vector BLOB NOT NULL, idf INTEGER NOT NULL);", + NULL, NULL, NULL) != SQLITE_OK || + sqlite3_prepare_v2(db, + "INSERT INTO token_vectors(project,token,vector,idf) " + "VALUES(?1,?2,?3,1)", + MCP_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return false; + } + const unsigned char malformed_vector[] = {0x7f}; + bool ok = sqlite3_bind_text(stmt, MCP_TEST_PROJECT_BIND, project, MCP_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC) == SQLITE_OK && + sqlite3_bind_text(stmt, MCP_TEST_TOKEN_BIND, token, MCP_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC) == SQLITE_OK && + sqlite3_bind_blob(stmt, MCP_TEST_VECTOR_BIND, malformed_vector, + (int)sizeof(malformed_vector), SQLITE_STATIC) == SQLITE_OK && + sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + return ok; +} +TEST(tool_search_graph_query_reports_dirty_metadata_without_hiding_results) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - char *resp = - cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "blocked")); - ASSERT_EQ(probe.begin_count, 1); - ASSERT_EQ(probe.end_count, 0); - ASSERT_STR_EQ(probe.begin_projects[0], project); - ASSERT_TRUE(cbm_file_exists(db_path)); - free(resp); + const char *proj = "dirty-query-metadata"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/dirty-query-metadata"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "dirtyquerymarker", + "dirty.query.marker", "src/dirty_query.c")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "src/dirty_query.c", + .observed_hash = "dirty-query-hash", + .observed_generation = 9, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); - probe.deny_begin_call = 0; - resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":146,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"dirty-query-metadata\"," + "\"query\":\"dirtyquerymarker\",\"limit\":5}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "deleted")); - ASSERT_EQ(probe.begin_count, 2); - ASSERT_EQ(probe.end_count, 1); - ASSERT_STR_EQ(probe.begin_projects[1], project); - ASSERT_STR_EQ(probe.end_projects[0], project); - ASSERT_FALSE(cbm_file_exists(db_path)); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "dirtyquerymarker")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "project has dirty files")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + free(inner); + free(resp); cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - cbm_rmdir(cache); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); PASS(); } -TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { - char root[CBM_SZ_1K]; - (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(root)); - +TEST(tool_search_graph_query_sees_file_delta_fts_updates) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - char args[CBM_SZ_2K]; - (void)snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"name\":\"GuardedIndex\"," - "\"mode\":\"fast\"}", - root); - int spawn_before = cbm_index_supervisor_spawn_count(); - char *response = cbm_mcp_handle_tool(srv, "index_repository", args); - int spawn_after = cbm_index_supervisor_spawn_count(); - - ASSERT_NOT_NULL(response); - ASSERT_NOT_NULL(strstr(response, "blocked")); - ASSERT_EQ(probe.begin_count, 1); - ASSERT_EQ(probe.end_count, 0); - ASSERT_STR_EQ(probe.begin_projects[0], "GuardedIndex"); - ASSERT_EQ(spawn_after, spawn_before); - - free(response); - cbm_mcp_server_free(srv); - (void)th_rmtree(root); - PASS(); -} + const char *proj = "fts-delta"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/fts-delta"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); -TEST(tool_get_architecture_empty) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_node_t old_node = {.project = proj, + .label = "Function", + .name = "obsolete", + .qualified_name = "fts-delta.obsolete", + .file_path = "src/status.c", + .start_line = 1, + .end_line = 3}; + cbm_store_file_delta_t old_delta = {.project = proj, + .rel_path = "src/status.c", + .generation = 1, + .nodes = &old_node, + .node_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(st, &old_delta), CBM_STORE_OK); + + cbm_node_t new_node = {.project = proj, + .label = "Function", + .name = "freshmarker", + .qualified_name = "fts-delta.freshmarker", + .file_path = "src/status.c", + .start_line = 1, + .end_line = 3}; + cbm_store_file_delta_t new_delta = {.project = proj, + .rel_path = "src/status.c", + .generation = 2, + .nodes = &new_node, + .node_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(st, &new_delta), CBM_STORE_OK); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":24,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"nonexistent\"}}}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":554,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-delta\",\"query\":\"freshmarker\"," + "\"limit\":5,\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - /* No store for nonexistent project — should return project error */ - ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "freshmarker")); + ASSERT_NULL(strstr(inner, "obsolete")); + free(inner); free(resp); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":555,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-delta\",\"query\":\"obsolete\"," + "\"limit\":5,\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NULL(strstr(inner, "obsolete")); + ASSERT_NULL(strstr(inner, "freshmarker")); + + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* Regression for #281: handle_get_architecture must actually call - * cbm_store_get_architecture and surface its sections. Before the fix - * only label/edge histograms were emitted regardless of which aspects - * were requested. The store-side arch_entry_points query reads - * properties.is_entry_point on Function nodes, so we tag one node and - * assert the resulting JSON surfaces an "entry_points" array containing - * the tagged function — which is impossible without the wiring. */ -TEST(tool_get_architecture_emits_populated_sections) { +TEST(tool_search_graph_query_uses_overlay_active_rows) { + enum { BASE_GENERATION = 1 }; cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "arch-test"; + const char *proj = "fts-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/fts-overlay"), CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/arch-test"); - cbm_node_t main_fn = {0}; - main_fn.project = proj; - main_fn.label = "Function"; - main_fn.name = "main"; - main_fn.qualified_name = "arch-test.cmd.main"; - main_fn.file_path = "cmd/main.go"; - main_fn.start_line = 1; - main_fn.end_line = 3; - main_fn.properties_json = "{\"is_entry_point\":true}"; - ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "obsoleteoverlay", + "fts-overlay.obsolete", "src/status.c")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh = {.project = proj, + .label = "Function", + .name = "freshoverlaymarker", + .qualified_name = "fts-overlay.fresh", + .file_path = "src/status.c", + .start_line = 7, + .end_line = 9, + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/status.c", + .generation = BASE_GENERATION, + .nodes = &fresh, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"arch-test\",\"aspects\":[\"all\"]}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":556,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-overlay\",\"query\":\"freshoverlaymarker\"," + "\"limit\":5}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "freshoverlaymarker")); + ASSERT_NULL(strstr(inner, "obsoleteoverlay")); + free(inner); + free(resp); - /* The handler always emits node/edge counts and schema histograms; - * those existed before #281. The "entry_points" array only appears - * when cbm_store_get_architecture is actually called and its result - * is serialized — which is exactly what #281 wires up. */ - ASSERT_NOT_NULL(strstr(inner, "entry_points:")); - ASSERT_NOT_NULL(strstr(inner, "main")); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":557,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-overlay\",\"query\":\"obsoleteoverlay\"," + "\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NULL(strstr(inner, "obsoleteoverlay")); + ASSERT_NULL(strstr(inner, "freshoverlaymarker")); free(inner); free(resp); @@ -3368,148 +3574,124 @@ TEST(tool_get_architecture_emits_populated_sections) { PASS(); } -/* Distills PR #560 (overview subset): "overview" must expand to a compact - * subset — every aspect EXCEPT file_tree. Before the fix, "overview" was not - * registered in either aspect gate (want_aspect in store.c, aspect_wanted in - * mcp.c), so aspects=["overview"] silently degraded to just - * {total_nodes,total_edges}. RED on unfixed code: no "entry_points" key. */ -TEST(tool_get_architecture_overview_compact_subset_pr560) { +TEST(tool_search_graph_query_uses_additive_overlay_without_tombstone) { + enum { BASE_GENERATION = 1 }; cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "arch560"; + const char *proj = "fts-overlay-additive"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/fts-overlay-additive"), + CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/arch560"); - - cbm_node_t main_fn = {0}; - main_fn.project = proj; - main_fn.label = "Function"; - main_fn.name = "main"; - main_fn.qualified_name = "arch560.cmd.main"; - main_fn.file_path = "cmd/main.go"; - main_fn.start_line = 1; - main_fn.end_line = 3; - main_fn.properties_json = "{\"is_entry_point\":true}"; - ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0); - /* A File node so the file_tree aspect has real content — makes the - * "overview drops file_tree" assertion below non-vacuous. */ - cbm_node_t file_node = {.project = proj, - .label = "File", - .name = "main.go", - .qualified_name = "arch560.cmd.main.go", - .file_path = "cmd/main.go"}; - ASSERT_GT(cbm_store_upsert_node(st, &file_node), 0); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "stableadditivemarker", + "fts-overlay-additive.stable", + "include/shared.h")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); - /* Sanity: with "all", both entry_points and file_tree surface. */ - char *resp_all = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":560,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"all\"]}}}"); - ASSERT_NOT_NULL(resp_all); - char *inner_all = extract_text_content(resp_all); - ASSERT_NOT_NULL(inner_all); - ASSERT_NOT_NULL(strstr(inner_all, "entry_points:")); - ASSERT_NOT_NULL(strstr(inner_all, "file_tree:")); - free(inner_all); - free(resp_all); + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh = {.project = proj, + .label = "Function", + .name = "freshadditivemarker", + .qualified_name = "fts-overlay-additive.fresh", + .file_path = "include/shared.h", + .start_line = 7, + .end_line = 9, + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "include/shared.h", + .generation = BASE_GENERATION, + .nodes = &fresh, + .node_count = 1}; + const cbm_store_file_delta_t *deltas[] = {&delta}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta_additions_batch(st, deltas, 1, + overlay_generation), + CBM_STORE_OK); - /* "overview": substantive content (entry_points, node_labels) but NO - * file_tree section. */ char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":561,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"overview\"]}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":558,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-overlay-additive\"," + "\"query\":\"freshadditivemarker\",\"limit\":5}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "entry_points:")); - ASSERT_NOT_NULL(strstr(inner, "node_labels:")); - ASSERT_NULL(strstr(inner, "file_tree:")); - + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_file_tombstones\":0")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_owned_nodes_visible\":1")); + ASSERT_NOT_NULL(strstr(inner, "freshadditivemarker")); + ASSERT_NULL(strstr(inner, "stableadditivemarker")); free(inner); free(resp); - cbm_mcp_server_free(srv); - PASS(); -} - -/* Distills PR #560 (server-side validation): unknown aspect tokens must be - * rejected with an isError result listing the valid values. Before the fix - * the JSON-Schema accepted any string and both aspect gates simply never - * matched, so a typo like "bogus_aspect" produced a silent near-empty payload - * with isError:false. RED on unfixed code: no isError, no "Unknown aspect". */ -TEST(tool_get_architecture_rejects_unknown_aspect_pr560) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); - - const char *proj = "arch560v"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/arch560v"); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":562,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"arch560v\",\"aspects\":[\"bogus_aspect\"]}}}"); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":559,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-overlay-additive\"," + "\"query\":\"stableadditivemarker\",\"limit\":5}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); - ASSERT_NOT_NULL(strstr(resp, "Unknown aspect 'bogus_aspect'")); - /* The error must teach the valid vocabulary, including the new token. */ - ASSERT_NOT_NULL(strstr(resp, "overview")); - ASSERT_NOT_NULL(strstr(resp, "file_tree")); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "stableadditivemarker")); + ASSERT_NULL(strstr(inner, "freshadditivemarker")); + free(inner); free(resp); cbm_mcp_server_free(srv); PASS(); } -/* Reproduce-first for #640: query handlers must accept the `project_name` - * alias, not only the canonical `project` key. list_projects surfaces the field - * as "name" and the error hint says "pass the project name", so a caller - * naturally passes `project_name`. With no alias, the handler reads key - * "project" -> NULL -> resolve_store bails before opening any .db -> "project - * not found or not indexed" even though the project is indexed. Mirrors - * tool_get_architecture_emits_populated_sections but with the alias key. */ -TEST(tool_get_architecture_accepts_project_name_alias_issue640) { +TEST(tool_search_graph_overlay_tokenless_query_uses_graph_filters) { + enum { BASE_GENERATION = 1 }; cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "alias640"; + const char *proj = "fts-overlay-tokenless"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/fts-overlay-tokenless"), CBM_STORE_OK); cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/alias640"); - cbm_node_t main_fn = {0}; - main_fn.project = proj; - main_fn.label = "Function"; - main_fn.name = "main"; - main_fn.qualified_name = "alias640.cmd.main"; - main_fn.file_path = "cmd/main.go"; - main_fn.start_line = 1; - main_fn.end_line = 3; - main_fn.properties_json = "{\"is_entry_point\":true}"; - ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0); + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh = {.project = proj, + .label = "Function", + .name = "tokenlessOverlayMarker", + .qualified_name = "fts-overlay-tokenless.fresh", + .file_path = "src/status.c", + .start_line = 7, + .end_line = 9, + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/status.c", + .generation = BASE_GENERATION, + .nodes = &fresh, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - /* Caller passes `project_name` (the natural guess) instead of `project`. */ char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":640,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project_name\":\"alias640\",\"aspects\":[\"all\"]}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":558,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"fts-overlay-tokenless\",\"query\":\"!!!\"," + "\"name_pattern\":\"tokenlessOverlayMarker\",\"limit\":5," + "\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - - /* RED before the alias: inner is the "project not found" error. - * GREEN after: the alias resolves and architecture sections surface. */ - ASSERT_NULL(strstr(inner, "project not found")); - ASSERT_NOT_NULL(strstr(inner, "entry_points:")); + ASSERT_NULL(strstr(inner, "search_graph query overlay read failed")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "tokenlessOverlayMarker")); free(inner); free(resp); @@ -3517,6465 +3699,14611 @@ TEST(tool_get_architecture_accepts_project_name_alias_issue640) { PASS(); } -/* Reproduce-first for #640: the alias must apply across query handlers, not - * just get_architecture. search_graph with `project_name` must resolve too. */ -TEST(tool_search_graph_accepts_project_name_alias_issue640) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_output_byte_budgets) { + enum { FIRST_RESPONSE_WITH_CONTEXT_BUDGET = 1200 }; + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); - - const char *proj = "alias640b"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/alias640b"); - - cbm_node_t fn = {0}; - fn.project = proj; - fn.label = "Function"; - fn.name = "WidgetHandler"; - fn.qualified_name = "alias640b.svc.WidgetHandler"; - fn.file_path = "svc/widget.go"; - fn.start_line = 1; - fn.end_line = 2; - ASSERT_GT(cbm_store_upsert_node(st, &fn), 0); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":641,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\"," - "\"arguments\":{\"project_name\":\"alias640b\",\"name_pattern\":\"Widget.*\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":46,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"label\":\"Function\",\"name_pattern\":\"HandleRequest\",\"limit\":5}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); + ASSERT_NOT_NULL(strstr(inner, "_context_architecture_status:")); + ASSERT_LT((int)strlen(inner), FIRST_RESPONSE_WITH_CONTEXT_BUDGET); + free(inner); + free(resp); - ASSERT_NULL(strstr(inner, "project not found")); - ASSERT_NOT_NULL(strstr(inner, "WidgetHandler")); + /* The one-shot context has its own bounded budget above. Keep the original + * recurring search payload ceiling unchanged on an otherwise identical + * second call. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":461,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"label\":\"Function\",\"name_pattern\":\"HandleRequest\",\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); + ASSERT_NULL(strstr(inner, "_context_architecture_status:")); + ASSERT_LT((int)strlen(inner), 600); + free(inner); + free(resp); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":47,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"test-project\"," + "\"function_name\":\"HandleRequest\",\"direction\":\"both\",\"depth\":2}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callees[")); + ASSERT_LT((int)strlen(inner), 800); free(inner); free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -/* #1025: agents pass the repo FOLDER name ("codebase-memory-mcp"), but - * indexed project names derive from the full path - * (E:\project\graph\x -> "E-project-graph-x"), so exact lookup fails with - * "project not found" while list_projects clearly shows the project. A - * passed name that matches exactly ONE indexed project as a segment-aligned - * tail ("-" suffix) must resolve to it; zero or several matches keep - * the existing error. Runs against real cache-dir .db files (the resolution - * scans filenames), so this test indexes real fixtures under an overridden - * CBM_CACHE_DIR. */ -static void i1025_write_repo(const char *dir, const char *fn_name) { - char path[CBM_SZ_512]; - snprintf(path, sizeof(path), "%s/mod.py", dir); - FILE *f = fopen(path, "w"); - if (!f) - return; - fprintf(f, "def %s(x):\n return x + 1\n", fn_name); - fclose(f); -} - -TEST(tool_project_arg_resolves_unique_tail_issue1025) { - char repo_a[CBM_SZ_256]; - char repo_b[CBM_SZ_256]; - char repo_c[CBM_SZ_256]; - char cache[CBM_SZ_256]; - snprintf(repo_a, sizeof(repo_a), "/tmp/cbm-i1025a-XXXXXX"); - snprintf(repo_b, sizeof(repo_b), "/tmp/cbm-i1025b-XXXXXX"); - snprintf(repo_c, sizeof(repo_c), "/tmp/cbm-i1025c-XXXXXX"); - snprintf(cache, sizeof(cache), "/tmp/cbm-i1025d-XXXXXX"); - if (!cbm_mkdtemp(repo_a) || !cbm_mkdtemp(repo_b) || !cbm_mkdtemp(repo_c) || - !cbm_mkdtemp(cache)) { - FAIL("mkdtemp failed"); - } - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); - - i1025_write_repo(repo_a, "unique_tail_target"); - i1025_write_repo(repo_b, "amb_one"); - i1025_write_repo(repo_c, "amb_two"); - - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_search_graph_blocks_internal_fields_and_compacts_json_properties) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); - char args[CBM_SZ_1K]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"E-project-graph-suffix1025\"}", - repo_a); - char *r = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(r); - free(r); - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"F-alpha-amb1025\"}", repo_b); - r = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(r); - free(r); - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"G-beta-amb1025\"}", repo_c); - r = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(r); - free(r); + cbm_node_t node = {.project = "test-project", + .label = "Function", + .name = "fpCarrier", + .qualified_name = "test-project.src.fpCarrier", + .file_path = "src/fp.go", + .start_line = 1, + .end_line = 2, + .properties_json = "{\"fp\":\"FPSENTINEL00\",\"sp\":\"SPSENTINEL00\"," + "\"bt\":\"BTSENTINEL00\",\"complexity\":7}"}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); - /* 1. Unique tail resolves (RED today: "project not found"). */ - r = cbm_mcp_handle_tool(srv, "search_graph", - "{\"project\":\"suffix1025\",\"name_pattern\":\".*target.*\"}"); - ASSERT_NOT_NULL(r); - if (strstr(r, "project not found")) { - fprintf(stderr, " [1025] FAIL unique tail did not resolve: %.200s\n", r); - } - ASSERT_NULL(strstr(r, "project not found")); - ASSERT_NOT_NULL(strstr(r, "unique_tail_target")); - free(r); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":45,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"name_pattern\":\"fpCarrier\",\"fields\":[\"fp\",\"sp\",\"bt\",\"complexity\"]," + "\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "fpCarrier")); + ASSERT_NULL(strstr(inner, "FPSENTINEL00")); + ASSERT_NULL(strstr(inner, "SPSENTINEL00")); + ASSERT_NULL(strstr(inner, "BTSENTINEL00")); + ASSERT_NOT_NULL(strstr(inner, "complexity")); + free(inner); + free(resp); - /* 2. Ambiguous tail stays an error (never guess between projects). */ - r = cbm_mcp_handle_tool(srv, "search_graph", - "{\"project\":\"amb1025\",\"name_pattern\":\".*\"}"); - ASSERT_NOT_NULL(r); - ASSERT_NOT_NULL(strstr(r, "project not found")); - free(r); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":46,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"name_pattern\":\"fpCarrier\",\"format\":\"json\",\"compact\":true,\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "FPSENTINEL00")); + ASSERT_NULL(strstr(inner, "SPSENTINEL00")); + ASSERT_NULL(strstr(inner, "BTSENTINEL00")); + ASSERT_NULL(strstr(inner, "complexity")); + free(inner); + free(resp); - /* 3. Exact full name keeps working unchanged. */ - r = cbm_mcp_handle_tool(srv, "search_graph", - "{\"project\":\"E-project-graph-suffix1025\"," - "\"name_pattern\":\".*target.*\"}"); - ASSERT_NOT_NULL(r); - ASSERT_NULL(strstr(r, "project not found")); - free(r); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":47,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"name_pattern\":\"fpCarrier\",\"format\":\"json\",\"compact\":true," + "\"fields\":[\"fp\",\"complexity\"],\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "FPSENTINEL00")); + ASSERT_NOT_NULL(strstr(inner, "complexity")); + free(inner); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"name_pattern\":\"fpCarrier\",\"format\":\"json\",\"compact\":false,\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "FPSENTINEL00")); + ASSERT_NOT_NULL(strstr(inner, "complexity")); + free(inner); + free(resp); cbm_mcp_server_free(srv); - if (saved_cache_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1); - free(saved_cache_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); - } - th_rmtree(repo_a); - th_rmtree(repo_b); - th_rmtree(repo_c); - th_rmtree(cache); + cleanup_snippet_dir(tmp); PASS(); } -/* Regression for #604: path scopes architecture totals and content. */ -TEST(tool_get_architecture_path_scoping) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_lean_defaults_schema_and_status) { + /* GUARDS for the lean-default contract (TOON round 2): + * 1. get_graph_schema must not advertise the blocked internal fields + * (fp/sp/bt) — the server refuses to emit them, so listing them in the + * schema invited agents to request fields they can never get. + * 2. index_status omits the git context block unless verbose:true — the + * worktree/shadow path variants only matter when debugging where an + * index lives. */ + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "arch-path"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, "/tmp/arch-path"); - - cbm_node_t pkg_global = {.project = proj, - .label = "Package", - .name = "Django", - .qualified_name = "arch-path.Django", - .file_path = "vendor/django/__init__.py"}; - cbm_store_upsert_node(st, &pkg_global); - - cbm_node_t pkg_local = {.project = proj, - .label = "Package", - .name = "hoa", - .qualified_name = "arch-path.hoa", - .file_path = "apps/hoa/main.go"}; - cbm_store_upsert_node(st, &pkg_local); - - cbm_node_t f_hoa = {.project = proj, - .label = "File", - .name = "main.go", - .qualified_name = "arch-path.apps.hoa.main.go", - .file_path = "apps/hoa/main.go"}; - cbm_store_upsert_node(st, &f_hoa); - - cbm_node_t f_other = {.project = proj, - .label = "File", - .name = "other.go", - .qualified_name = "arch-path.other.go", - .file_path = "lib/other.go"}; - cbm_store_upsert_node(st, &f_other); - - char *resp_root = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"arch-path\",\"aspects\":[\"packages\"]}}}"); - ASSERT_NOT_NULL(resp_root); - char *inner_root = extract_text_content(resp_root); - ASSERT_NOT_NULL(inner_root); - ASSERT_NOT_NULL(strstr(inner_root, "Django")); - - char *resp_scoped = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_architecture\"," - "\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\"," - "\"aspects\":[\"packages\"]}}}"); - ASSERT_NOT_NULL(resp_scoped); - char *inner_scoped = extract_text_content(resp_scoped); - ASSERT_NOT_NULL(inner_scoped); - - ASSERT_NOT_NULL(strstr(inner_scoped, "root_total_nodes")); - ASSERT_NOT_NULL(strstr(inner_scoped, "scoped_total_nodes")); - ASSERT_NOT_NULL(strstr(inner_scoped, "path: ")); - ASSERT_NOT_NULL(strstr(inner_scoped, "hoa")); - ASSERT_NULL(strstr(inner_scoped, "Django")); - - int root_nodes = 0; - int scoped_nodes = 0; - /* TOON scalar form (`key: N`) with JSON fallback for format:"json". */ - const char *rt = strstr(inner_scoped, "root_total_nodes: "); - const char *stn = strstr(inner_scoped, "scoped_total_nodes: "); - if (rt) { - sscanf(rt, "root_total_nodes: %d", &root_nodes); - } else if ((rt = strstr(inner_scoped, "\"root_total_nodes\":")) != NULL) { - sscanf(rt, "\"root_total_nodes\":%d", &root_nodes); - } - if (stn) { - sscanf(stn, "scoped_total_nodes: %d", &scoped_nodes); - } else if ((stn = strstr(inner_scoped, "\"scoped_total_nodes\":")) != NULL) { - sscanf(stn, "\"scoped_total_nodes\":%d", &scoped_nodes); - } - ASSERT_TRUE(root_nodes > scoped_nodes); - ASSERT_TRUE(scoped_nodes > 0); - - free(inner_scoped); - free(resp_scoped); - free(inner_root); - free(resp_root); - cbm_mcp_server_free(srv); - PASS(); -} - -TEST(tool_query_graph_missing_query) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_node_t n = {0}; + n.project = "test-project"; + n.label = "Function"; + n.name = "schemaCarrier"; + n.qualified_name = "test-project.src.schemaCarrier"; + n.file_path = "src/sc.go"; + n.start_line = 1; + n.end_line = 2; + n.properties_json = "{\"fp\":\"x\",\"sp\":\"y\",\"bt\":\"z\",\"complexity\":3}"; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":23,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"query_graph\"," - "\"arguments\":{}}}"); + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_graph_schema\"," + "\"arguments\":{\"project\":\"test-project\"}}}"); ASSERT_NOT_NULL(resp); - /* Should return error about missing query */ - ASSERT_NOT_NULL(strstr(resp, "required")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "Function")); /* non-vacuous: label present */ + ASSERT_NOT_NULL(strstr(inner, "complexity")); /* obtainable property listed */ + ASSERT_NULL(strstr(inner, "\"fp\"")); /* blocked fields not advertised */ + ASSERT_NULL(strstr(inner, "\"sp\"")); + ASSERT_NULL(strstr(inner, "\"bt\"")); + free(inner); free(resp); - cbm_mcp_server_free(srv); - PASS(); -} - -/* ══════════════════════════════════════════════════════════════════ - * PIPELINE-DEPENDENT TOOL HANDLERS - * ══════════════════════════════════════════════════════════════════ */ - -TEST(tool_index_repository_missing_path) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + /* index_status: no git block by default... */ + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":49,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status\"")); + ASSERT_NULL(strstr(inner, "\"git\"")); + free(inner); + free(resp); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_repository\"," - "\"arguments\":{}}}"); + /* ...and present with verbose:true. */ + resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "required")); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"git\"")); + free(inner); free(resp); cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -TEST(tool_get_code_snippet_missing_qn) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +/* ── Tool-output regression suite (gating) ────────────────────────── + * Context-explosion detector: flags the measured smells that re-introduce + * token bloat into default outputs, independent of any specific tool: + * 1. blocked internal fields (fp/sp/bt) appearing anywhere; + * 2. repeated-key JSON envelopes — the same key emitted per row instead of + * a header-once table (the un-TOONed enumeration smell; detect_changes + * shipped 4,787x3 of these = 416KB); + * 3. embedded prose notes/hints beyond one line (~220 chars) — long prose + * belongs in tool descriptions or docs, not repeated per response. + * Returns NULL when clean, else a static description of the violation. */ +static const char *output_explosion_smell(const char *inner) { + static const char *row_keys[] = { + "\"name\":", "\"label\":", "\"file\":", "\"path\":", "\"qualified_name\":", "\"qn\":"}; + if (strstr(inner, "\"fp\":") || strstr(inner, "\"sp\":") || strstr(inner, "\"bt\":")) { + return "blocked internal field (fp/sp/bt) leaked into output"; + } + for (size_t k = 0; k < sizeof(row_keys) / sizeof(row_keys[0]); k++) { + int n = 0; + for (const char *p = strstr(inner, row_keys[k]); p && n <= 32; + p = strstr(p + 1, row_keys[k])) { + n++; + } + if (n > 32) { + return "repeated-key envelope (>32x same JSON key) — emit a header-once table"; + } + } + for (const char *p = strstr(inner, "\"note\":\""); p; p = strstr(p + 1, "\"note\":\"")) { + const char *end = strchr(p + 9, '"'); + while (end && end[-1] == '\\') { + end = strchr(end + 1, '"'); + } + if (end && end - (p + 9) > 220) { + return "embedded note exceeds one line (~220 chars)"; + } + } + return NULL; +} - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_code_snippet\"," - "\"arguments\":{}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "required")); +/* Run one tool call on the fixture server, apply the explosion detector and + * an absolute byte ceiling, and require a semantic-floor marker so trimming + * can never hollow the response out either. */ +static const char *check_tool_output(cbm_mcp_server_t *srv, const char *req, int ceiling, + const char *floor_marker) { + char *resp = cbm_mcp_server_handle(srv, req); + if (!resp) { + return "no response"; + } + char *inner = extract_text_content(resp); free(resp); + if (!inner) { + return "no text content"; + } + static char why[256]; + const char *smell = output_explosion_smell(inner); + if (smell) { + snprintf(why, sizeof(why), "%s", smell); + free(inner); + return why; + } + if ((int)strlen(inner) >= ceiling) { + snprintf(why, sizeof(why), "output %d B >= ceiling %d B", (int)strlen(inner), ceiling); + free(inner); + return why; + } + if (floor_marker && !strstr(inner, floor_marker)) { + snprintf(why, sizeof(why), "semantic floor missing: %s", floor_marker); + free(inner); + return why; + } + free(inner); + return NULL; +} + +TEST(tool_output_regression_gate) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + struct { + const char *req; + int ceiling; + const char *floor; + } cases[] = { + {"{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"name_pattern\":\".*\",\"limit\":50}}}", + 6000, "results["}, + {"{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_graph_schema\",\"arguments\":{\"project\":\"test-project\"}}}", + 6000, "node_labels"}, + {"{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"index_status\",\"arguments\":{\"project\":\"test-project\"}}}", + 7000, "\"status\""}, + {"{\"jsonrpc\":\"2.0\",\"id\":73,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"test-project\"," + "\"function_name\":\"HandleRequest\",\"direction\":\"both\"}}}", + 1500, "callees["}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const char *why = check_tool_output(srv, cases[i].req, cases[i].ceiling, cases[i].floor); + if (why) { + char msg[320]; + snprintf(msg, sizeof(msg), "case %d: %s", (int)i, why); + FAIL(msg); + } + } cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -TEST(tool_get_code_snippet_not_found) { +TEST(tool_search_graph_query_honors_file_pattern_issue552) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "issue-552"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/issue-552"); + + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "status", + "issue-552.src.lib.status", "src/lib/status.c")); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "status", + "issue-552.src.components.status", + "src/components/status.c")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":32,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"get_code_snippet\"," - "\"arguments\":{\"qualified_name\":\"nonexistent.func\"," - "\"project\":\"nonexistent\"}}}"); + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\"," + "\"file_pattern\":\"src/lib/*\",\"limit\":10}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "not found")); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "search_mode: bm25")); + ASSERT_NOT_NULL(strstr(inner, "src/lib/status.c")); + ASSERT_NULL(strstr(inner, "src/components/status.c")); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_search_code_missing_pattern) { +TEST(tool_search_graph_query_uses_search_limit_config) { + char *tmp = th_mktempdir("cbm_mcp_bm25_limit"); + ASSERT_NOT_NULL(tmp); + char cfg_dir[512]; + int n = snprintf(cfg_dir, sizeof(cfg_dir), "%s", tmp); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(cfg_dir)); + + cbm_config_t *cfg = cbm_config_open(cfg_dir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SEARCH_LIMIT, "1"), 0); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":33,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{}}}"); + const char *proj = "bm25-limit"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/bm25-limit"); + + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "status_ready", + "bm25-limit.src.status_ready", + "src/status_ready.c")); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "status_pending", + "bm25-limit.src.status_pending", + "src/status_pending.c")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":554,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"bm25-limit\",\"query\":\"status\"," + "\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "required")); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "search_mode")), "bm25"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "has_more"))); + yyjson_val *results = yyjson_obj_get(root, "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ(yyjson_arr_size(results), 1); + yyjson_doc_free(doc); + free(inner); + free(resp); cbm_mcp_server_free(srv); + cbm_config_close(cfg); + th_rmtree(cfg_dir); PASS(); } -TEST(tool_search_code_no_project) { +TEST(tool_search_graph_query_rejects_bad_semantic_query) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":34,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"func main\"," - "\"project\":\"nonexistent\"}}}"); + const char *proj = "bm25-semantic"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/bm25-semantic"); + + ASSERT_TRUE(mcp_test_upsert_fts_node(st, proj, "Function", "publish_status", + "bm25-semantic.src.publish_status", "src/status.c")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":553,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"bm25-semantic\",\"query\":\"status\"," + "\"semantic_query\":\"publish\"}}}"); ASSERT_NOT_NULL(resp); - /* No project indexed → error */ - ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed") || - strstr(resp, "required")); - free(resp); + /* Recognized-tool validation remains a CallToolResult execution error; + * only malformed protocol envelopes and unknown names use JSON-RPC error. */ + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NULL(strstr(resp, "\"error\":{\"code\":")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "semantic_query must be an array")); + ASSERT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(search_code_multi_word) { - char tmp[512]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +TEST(tool_search_graph_semantic_query_rejects_non_string_array_items) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_project(srv, "semantic-item-type"); - /* Multi-word query "HandleRequest error" — should find the line - * "func HandleRequest() error {" via regex conversion. */ - char req[512]; - snprintf(req, sizeof(req), - "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest error\"," - "\"project\":\"test-project\"}}}"); - - char *resp = cbm_mcp_server_handle(srv, req); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":555,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"semantic-item-type\"," + "\"semantic_query\":[\"publish\",7],\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - /* Should find at least one result (not zero) */ - ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL); - /* Should NOT contain an error about "not found" */ - ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); - free(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "array of keyword strings")); + ASSERT_NULL(strstr(inner, "semantic_results")); - cleanup_snippet_dir(tmp); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* Reproduce-first (#687): scoped content search over a repo whose ROOT PATH - * contains a space. write_scoped_filelist emits "/" records that the - * Unix pipeline pipes to grep via xargs. With plain `xargs` (newline-split) the - * space splits one path into several bogus args -> grep finds nothing -> - * total_grep_matches == 0 (RED on the unfixed code). The fix writes NUL-separated - * records + uses `xargs -0`, so the path stays a single argument -> match found - * (GREEN). On Windows the scoped path uses PowerShell Get-Content -LiteralPath, - * which already handles spaces, so this asserts correct behavior there too. */ -TEST(search_code_scoped_path_with_spaces_issue687) { - char tmp[512]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_srch_space_XXXXXX"); - if (!cbm_mkdtemp(tmp)) { - FAIL("cbm_mkdtemp failed"); - } - - /* Project root deliberately contains a space. */ - char proj_dir[640]; - snprintf(proj_dir, sizeof(proj_dir), "%s/my project", tmp); - cbm_mkdir(proj_dir); - - char src_path[768]; - snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir); - FILE *fp = fopen(src_path, "w"); - if (!fp) { - rmdir(proj_dir); - rmdir(tmp); - FAIL("cannot write source file under spaced path"); - } - fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); - fclose(fp); - +TEST(tool_search_graph_semantic_query_without_vector_tables_is_empty_not_error) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "space-search"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, proj_dir); - - /* A node so the file is "indexed" (cbm_store_list_files -> scoped grep path) - * and the grep hit classifies to a result. */ - cbm_node_t n = {.project = proj, - .label = "Function", - .name = "HandleRequest", - .qualified_name = "space-search.main.HandleRequest", - .file_path = "main.go", - .start_line = 3, - .end_line = 5}; - ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + const char *project = "semantic-capability-absent"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/semantic-capability-absent"), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_exec(st, "DROP TABLE IF EXISTS node_vectors;" + "DROP TABLE IF EXISTS token_vectors;"), + CBM_STORE_OK); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"space-search\"}}}"); + /* Pin both explicit encodings without duplicating configurable-default + * precedence tests. The product default remains TOON; smoke B3 exercises + * that default through the CLI. Capability absence is store-level. */ + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":554,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"semantic-capability-absent\",\"format\":\"json\"," + "\"semantic_query\":[\"send\",\"publish\"]}}}"); ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"isError\":true")); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *semantic_results = yyjson_obj_get(yyjson_doc_get_root(doc), "semantic_results"); + ASSERT_NOT_NULL(semantic_results); + ASSERT_TRUE(yyjson_is_arr(semantic_results)); + ASSERT_EQ(yyjson_arr_size(semantic_results), 0); + ASSERT_NULL(strstr(inner, "Exact semantic search failed")); + yyjson_doc_free(doc); + free(inner); + free(resp); - /* grep must have found the match despite the space in the root path. */ - int grep_matches = -1; - const char *g = strstr(inner, "\"total_grep_matches\":"); - if (g) { - sscanf(g, "\"total_grep_matches\":%d", &grep_matches); - } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { - /* TOON scalar form — the search_code compact default. */ - sscanf(g, "total_grep_matches: %d", &grep_matches); - } - ASSERT_TRUE(grep_matches > 0); + resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":555,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"semantic-capability-absent\",\"format\":\"toon\"," + "\"semantic_query\":[\"send\",\"publish\"]}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"isError\":true")); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + /* Match scripts/smoke-test.sh B3: repeated CLI array flags become these + * two keywords, and a capability-absent semantic-only TOON response must + * retain its empty table header. */ + ASSERT_NOT_NULL(strstr(inner, "semantic[0]")); + ASSERT_NULL(strstr(inner, "Exact semantic search failed")); free(inner); free(resp); cbm_mcp_server_free(srv); - unlink(src_path); - rmdir(proj_dir); - rmdir(tmp); PASS(); } -#ifdef _WIN32 -/* Issue #903 follow-up: scoped search_code on Windows writes a UTF-8 filelist - * containing absolute source paths, then reads it back through PowerShell. - * Windows PowerShell 5.1 treats UTF-8 without BOM as ANSI unless told - * otherwise, so a non-ASCII project root can be mojibaked before - * Select-String sees the LiteralPath. */ -TEST(search_code_scoped_path_with_cjk_root_issue903) { - char tmp[512]; - snprintf(tmp, sizeof(tmp), "%s/cbm_srch_cjk_XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(tmp)) { - FAIL("cbm_mkdtemp failed"); - } +TEST(tool_search_graph_semantic_query_keyword_allocation_failure_is_atomic) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_project(srv, "semantic-keyword-allocation"); + cbm_mcp_test_fail_next_semantic_keyword_allocation(); - char proj_dir[640]; - snprintf(proj_dir, sizeof(proj_dir), "%s/%s", tmp, - "\xE4\xB8\xAD\xE6\x96\x87\xE9\xA1\xB9\xE7\x9B\xAE"); - if (!cbm_mkdir_p(proj_dir, 0755)) { - cbm_rmdir(tmp); - FAIL("cannot create CJK project dir"); - } + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":559,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"semantic-keyword-allocation\"," + "\"semantic_query\":[\"publish\"],\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "complete semantic_query without truncation")); + ASSERT_NULL(strstr(inner, "semantic_results")); - char src_path[768]; - snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir); - FILE *fp = cbm_fopen(src_path, "wb"); - if (!fp) { - cbm_rmdir(proj_dir); - cbm_rmdir(tmp); - FAIL("cannot write source file under CJK path"); - } - fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); - fclose(fp); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} +TEST(tool_search_graph_semantic_query_propagates_keyword_33_store_error) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - const char *proj = "cjk-search"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, proj_dir); - - cbm_node_t n = {.project = proj, - .label = "Function", - .name = "HandleRequest", - .qualified_name = "cjk-search.main.HandleRequest", - .file_path = "main.go", - .start_line = 3, - .end_line = 5}; - ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + const char *project = "semantic-keyword-33"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/semantic-keyword-33"), CBM_STORE_OK); + ASSERT_TRUE(mcp_test_install_malformed_token_vector(st, project, "keyword_32")); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":903,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"cjk-search\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":556,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"semantic-keyword-33\",\"format\":\"json\"," + "\"semantic_query\":[" + "\"keyword_0\",\"keyword_1\",\"keyword_2\",\"keyword_3\"," + "\"keyword_4\",\"keyword_5\",\"keyword_6\",\"keyword_7\"," + "\"keyword_8\",\"keyword_9\",\"keyword_10\",\"keyword_11\"," + "\"keyword_12\",\"keyword_13\",\"keyword_14\",\"keyword_15\"," + "\"keyword_16\",\"keyword_17\",\"keyword_18\",\"keyword_19\"," + "\"keyword_20\",\"keyword_21\",\"keyword_22\",\"keyword_23\"," + "\"keyword_24\",\"keyword_25\",\"keyword_26\",\"keyword_27\"," + "\"keyword_28\",\"keyword_29\",\"keyword_30\",\"keyword_31\"," + "\"keyword_32\"]}}}"); ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - - int grep_matches = -1; - const char *g = strstr(inner, "\"total_grep_matches\":"); - if (g) { - sscanf(g, "\"total_grep_matches\":%d", &grep_matches); - } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { - /* TOON scalar form — the search_code compact default. */ - sscanf(g, "total_grep_matches: %d", &grep_matches); - } - ASSERT_TRUE(grep_matches > 0); + ASSERT_NOT_NULL(strstr(inner, "token vector has invalid dimension")); + ASSERT_NULL(strstr(inner, "semantic_results")); free(inner); free(resp); cbm_mcp_server_free(srv); - cbm_unlink(src_path); - cbm_rmdir(proj_dir); - cbm_rmdir(tmp); PASS(); } -#endif - -/* Shared fixture for the path_filter prefilter tests (PR #756 distilled): - * a project with two indexed files that both contain the search pattern — - * src/handler.go (inside the filter) and vendor/other.go (outside it). */ -static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path, - size_t src_sz, char *vendor_path, - size_t vendor_sz) { - snprintf(tmp, tmp_sz, "/tmp/cbm_srch_pref_XXXXXX"); - if (!cbm_mkdtemp(tmp)) { - return NULL; - } - char dir[640]; - snprintf(dir, sizeof(dir), "%s/src", tmp); - cbm_mkdir(dir); - snprintf(dir, sizeof(dir), "%s/vendor", tmp); - cbm_mkdir(dir); - - snprintf(src_path, src_sz, "%s/src/handler.go", tmp); - snprintf(vendor_path, vendor_sz, "%s/vendor/other.go", tmp); - FILE *fp = fopen(src_path, "w"); - if (!fp) { - return NULL; - } - fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); - fclose(fp); - fp = fopen(vendor_path, "w"); - if (!fp) { - return NULL; - } - fprintf(fp, "package vendored\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); - fclose(fp); +TEST(tool_search_graph_semantic_query_propagates_store_error_in_toon) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - return NULL; - } + ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); - const char *proj = "prefilter-search"; - cbm_mcp_server_set_project(srv, proj); - cbm_store_upsert_project(st, proj, tmp); + ASSERT_NOT_NULL(st); + const char *project = "semantic-store-error-toon"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/semantic-store-error-toon"), + CBM_STORE_OK); + ASSERT_TRUE(mcp_test_install_malformed_token_vector(st, project, "broken")); - cbm_node_t n1 = {.project = proj, - .label = "Function", - .name = "HandleRequest", - .qualified_name = "prefilter-search.main.HandleRequest", - .file_path = "src/handler.go", - .start_line = 3, - .end_line = 5}; - cbm_node_t n2 = {.project = proj, - .label = "Function", - .name = "HandleRequest", - .qualified_name = "prefilter-search.vendored.HandleRequest", - .file_path = "vendor/other.go", - .start_line = 3, - .end_line = 5}; - if (cbm_store_upsert_node(st, &n1) <= 0 || cbm_store_upsert_node(st, &n2) <= 0) { - cbm_mcp_server_free(srv); - return NULL; - } - return srv; -} + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":557,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"semantic-store-error-toon\"," + "\"semantic_query\":[\"broken\"]}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "token vector has invalid dimension")); + ASSERT_NULL(strstr(inner, "semantic[")); -static void cleanup_prefilter_dir(const char *tmp, const char *src_path, const char *vendor_path) { - char dir[640]; - unlink(src_path); - unlink(vendor_path); - snprintf(dir, sizeof(dir), "%s/src", tmp); - rmdir(dir); - snprintf(dir, sizeof(dir), "%s/vendor", tmp); - rmdir(dir); - rmdir(tmp); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); } -/* PR #756 (distilled): scoped search_code prefilters the indexed filelist by - * path_filter before grep runs. POSITIVE invariant guard: a path_filter that - * matches the file containing the hit must still return that hit (guards - * against over-filtering — the prefilter predicate must stay IDENTICAL to the - * post-grep filter in collect_grep_matches), and files outside the filter - * stay excluded. Green on pre-prefilter main too (the post-grep filter alone - * produced the same results): the change is results-preserving perf-only. */ -TEST(search_code_path_filter_prefilter_keeps_matches) { - char tmp[512], src_path[768], vendor_path[768]; - cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), - vendor_path, sizeof(vendor_path)); +TEST(tool_search_graph_semantic_query_does_not_mask_store_error_with_graph_json) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *project = "semantic-store-error-graph-json"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/semantic-store-error-graph-json"), + CBM_STORE_OK); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, project, "Function", "graph_partial_marker", + "semantic.graph_partial_marker", "src/graph.c")); + ASSERT_TRUE(mcp_test_install_malformed_token_vector(st, project, "broken")); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," - "\"path_filter\":\"^src/\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":560,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"semantic-store-error-graph-json\",\"format\":\"json\"," + "\"name_pattern\":\"graph_partial_marker\"," + "\"semantic_query\":[\"broken\"]}}}"); ASSERT_NOT_NULL(resp); - ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - - /* The in-filter hit is returned; the out-of-filter file is not. */ - ASSERT_NOT_NULL(strstr(inner, "src/handler.go")); - ASSERT_TRUE(strstr(inner, "vendor/other.go") == NULL); - - /* Exactly the one in-filter grep match survives (same count before and - * after the prefilter — predicate identity). */ - int grep_matches = -1; - const char *g = strstr(inner, "\"total_grep_matches\":"); - if (g) { - sscanf(g, "\"total_grep_matches\":%d", &grep_matches); - } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { - /* TOON scalar form — the search_code compact default. */ - sscanf(g, "total_grep_matches: %d", &grep_matches); - } - ASSERT_EQ(grep_matches, 1); + ASSERT_NOT_NULL(strstr(inner, "token vector has invalid dimension")); + ASSERT_NULL(strstr(inner, "graph_partial_marker")); + ASSERT_NULL(strstr(inner, "semantic_results")); free(inner); free(resp); cbm_mcp_server_free(srv); - cleanup_prefilter_dir(tmp, src_path, vendor_path); PASS(); } -/* PR #756 (distilled): path_filter matching ZERO indexed files. With the - * prefilter the scoped filelist has 0 records, and handle_search_code now - * skips the grep subprocess entirely (xargs on an empty filelist is - * platform-dependent: GNU execs grep once with no operands, BSD skips) and - * returns the empty result directly. Must be a clean zero-result response — - * no error. Green on pre-prefilter main too (there the full filelist is - * grepped and the post-grep filter drops every hit — an empty filelist is - * unreachable on main): guards the edge the prefilter introduces. */ -TEST(search_code_path_filter_matches_nothing) { - char tmp[512], src_path[768], vendor_path[768]; - cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), - vendor_path, sizeof(vendor_path)); +TEST(tool_search_graph_semantic_query_does_not_mask_store_error_with_bm25) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *project = "semantic-store-error-bm25"; + cbm_mcp_server_set_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/semantic-store-error-bm25"), + CBM_STORE_OK); + ASSERT_TRUE(mcp_test_upsert_fts_node(st, project, "Function", "bm25_partial_marker", + "semantic.bm25_partial_marker", "src/bm25.c")); + ASSERT_EQ(mcp_test_rebuild_nodes_fts(st), CBM_STORE_OK); + ASSERT_TRUE(mcp_test_install_malformed_token_vector(st, project, "broken")); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," - "\"path_filter\":\"^no_such_dir/\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":558,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"semantic-store-error-bm25\",\"query\":\"partial\"," + "\"semantic_query\":[\"broken\"]}}}"); ASSERT_NOT_NULL(resp); - ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - - int grep_matches = -1; - const char *g = strstr(inner, "\"total_grep_matches\":"); - if (g) { - sscanf(g, "\"total_grep_matches\":%d", &grep_matches); - } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { - /* TOON scalar form — the search_code compact default. */ - sscanf(g, "total_grep_matches: %d", &grep_matches); - } - ASSERT_EQ(grep_matches, 0); - int results = -1; - const char *r = strstr(inner, "\"total_results\":"); - if (r) { - sscanf(r, "\"total_results\":%d", &results); - } else if ((r = strstr(inner, "total_results: ")) != NULL) { - sscanf(r, "total_results: %d", &results); - } - ASSERT_EQ(results, 0); - ASSERT_TRUE(strstr(inner, "handler.go") == NULL); - ASSERT_TRUE(strstr(inner, "other.go") == NULL); + ASSERT_NOT_NULL(strstr(inner, "token vector has invalid dimension")); + ASSERT_NULL(strstr(inner, "bm25_partial_marker")); + ASSERT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); free(inner); free(resp); cbm_mcp_server_free(srv); - cleanup_prefilter_dir(tmp, src_path, vendor_path); PASS(); } -/* issue #283: search_code with regex=true and a syntactically invalid pattern - * must return an explicit error, not an empty result indistinguishable from a - * legitimate no-match. */ -TEST(search_code_invalid_regex_errors_issue283) { - char tmp[512]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +TEST(tool_search_graph_semantic_query_warns_on_stale_semantic_view) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - /* Unclosed group under regex=true → must be flagged as an error. */ - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"func(\",\"regex\":true," - "\"project\":\"test-project\"}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); - ASSERT_NOT_NULL(strstr(resp, "invalid regex")); - free(resp); + const char *proj = "semantic-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/semantic-stale"), CBM_STORE_OK); + ASSERT_TRUE(mcp_test_install_empty_vector_tables(st)); + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); - /* Same pattern as a literal (regex=false) must NOT error. */ - resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"func(\",\"regex\":false," - "\"project\":\"test-project\"}}}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"semantic-stale\"," + "\"semantic_query\":[\"publish\"],\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_TRUE(strstr(resp, "invalid regex") == NULL); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "semantic_edges derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES)); - cleanup_snippet_dir(tmp); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* issue #282: a literal '|' under regex=false is a silent 0-match trap. It must - * now be surfaced as a warning (and the result carries elapsed_ms). */ -TEST(search_code_literal_pipe_warns_issue282) { - char tmp[512]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +TEST(tool_search_graph_semantic_only_json_does_not_return_unfiltered_nodes) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest|Nope\"," - "\"regex\":false,\"project\":\"test-project\"}}}"); + const char *proj = "semantic-only-json"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/semantic-only-json"), CBM_STORE_OK); + ASSERT_TRUE(mcp_test_install_empty_vector_tables(st)); + cbm_mcp_server_set_project(srv, proj); + + /* A graph node without a semantic vector proves the handler does not + * silently substitute an unrelated unfiltered graph search when the + * semantic-only request has no matches. */ + cbm_node_t unrelated = {.project = proj, + .label = "Function", + .name = "unrelated_ranked_function", + .qualified_name = "semantic.only.unrelated_ranked_function", + .file_path = "src/unrelated.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &unrelated), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":481,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"semantic-only-json\"," + "\"semantic_query\":[\"transport\",\"lifecycle\"],\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "warning")); /* surfaced, not silent */ - ASSERT_NOT_NULL(strstr(resp, "regex=true")); /* the hint names the fix */ - ASSERT_NOT_NULL(strstr(resp, "elapsed_ms")); /* timing is reported */ - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); - cleanup_snippet_dir(tmp); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + yyjson_val *semantic_results = yyjson_obj_get(root, "semantic_results"); + ASSERT_NOT_NULL(results); + ASSERT_TRUE(yyjson_is_arr(results)); + ASSERT_EQ(yyjson_arr_size(results), 0); + ASSERT_NOT_NULL(semantic_results); + ASSERT_TRUE(yyjson_is_arr(semantic_results)); + ASSERT_EQ(yyjson_arr_size(semantic_results), 0); + ASSERT_NOT_NULL(yyjson_obj_get(root, "hint")); + ASSERT_NULL(strstr(inner, "unrelated_ranked_function")); + + yyjson_doc_free(doc); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* issue #272: '&' in a path / file_pattern is neutralised by the command's - * quoting and must no longer be rejected as "invalid characters". */ -TEST(search_code_ampersand_accepted_issue272) { - char tmp[512]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +/* MCP discovery probes must return valid lists, not -32601 Method-not-found: + * clients like Cline call these on connect and + * resources/list + prompts/list + resources/templates/list on connect and + * surface the errors as a failed connection (#958). */ +TEST(mcp_discovery_methods_return_supported_lists) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_code\"," - "\"arguments\":{\"pattern\":\"HandleRequest\"," - "\"file_pattern\":\"*R&D*.go\",\"project\":\"test-project\"}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_TRUE(strstr(resp, "invalid characters") == NULL); - free(resp); + struct { + const char *method; + const char *want; + } cases[] = { + {"resources/list", "\"resources\":["}, + {"prompts/list", "\"prompts\":["}, + {"resources/templates/list", "\"resourceTemplates\":[]"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char reqbuf[256]; + snprintf(reqbuf, sizeof(reqbuf), "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", + 100 + (int)i, cases[i].method); + char *resp = cbm_mcp_server_handle(srv, reqbuf); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "Method not found")); + ASSERT_NOT_NULL(strstr(resp, cases[i].want)); + free(resp); + } - cleanup_snippet_dir(tmp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_detect_changes_no_project) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_query_graph_basic) { + cbm_mcp_server_t *srv = setup_mcp_with_data(); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":35,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"detect_changes\"," - "\"arguments\":{}}}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":14,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"query\":\"MATCH (f:Function) RETURN f.name\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "missing required argument: project")); + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_manage_adr_no_project) { +TEST(tool_query_graph_chained_with_optional_multi_order_formats) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-stage-formats"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-stage-formats"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":36,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\"," - "\"arguments\":{}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "missing required argument: project")); - free(resp); + const char *names[] = {"CallerA", "Target", "CallerC", "Leaf"}; + int64_t ids[4] = {0}; + for (int i = 0; i < 4; i++) { + char qn[CBM_SZ_128]; + snprintf(qn, sizeof(qn), "query.stage.%s", names[i]); + cbm_node_t node = {.project = proj, + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "src/stage.c"}; + ids[i] = cbm_store_upsert_node(st, &node); + ASSERT_GT(ids[i], 0); + } + const int endpoints[][2] = {{0, 1}, {2, 1}, {1, 3}}; + for (int i = 0; i < 3; i++) { + cbm_edge_t edge = {.project = proj, + .source_id = ids[endpoints[i][0]], + .target_id = ids[endpoints[i][1]], + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + } + + const char *formats[] = {"toon", "json"}; + for (int i = 0; i < 2; i++) { + char request[CBM_SZ_2K]; + snprintf(request, sizeof(request), + "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\",\"arguments\":{" + "\"project\":\"query-stage-formats\",\"format\":\"%s\"," + "\"query\":\"MATCH (caller:Function)-[:CALLS]->(target:Function) " + "WITH target, count(DISTINCT caller) AS callers " + "OPTIONAL MATCH (target)-[:CALLS]->(next:Function) " + "RETURN target.name AS target, callers, next.name AS next " + "ORDER BY callers DESC, target ASC\"}}}", + 160 + i, formats[i]); + char *resp = cbm_mcp_server_handle(srv, request); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "Target")); + ASSERT_NOT_NULL(strstr(inner, "Leaf")); + ASSERT_NOT_NULL(strstr(inner, "2")); + free(inner); + free(resp); + } cbm_mcp_server_free(srv); PASS(); } -/* Regression test for use-after-free in handle_manage_adr (get path). - * MUST FAIL before fix: free(buf) is called before yy_doc_to_str serializes doc, - * so result field is missing or contains garbage. MUST PASS after fix. */ -TEST(tool_manage_adr_get_with_existing_adr) { - /* Create a temp directory with .codebase-memory/adr.md */ - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-test-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); /* skip if mkdtemp fails */ - } - - char adr_dir[512]; - snprintf(adr_dir, sizeof(adr_dir), "%s/.codebase-memory", tmp_dir); - cbm_mkdir(adr_dir); - - char adr_path[512]; - snprintf(adr_path, sizeof(adr_path), "%s/adr.md", adr_dir); - FILE *fp = fopen(adr_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("## PURPOSE\nTest ADR content for regression test.\n\n" - "## STACK\nC, SQLite.\n\n" - "## ARCHITECTURE\nMCP server.\n", - fp); - fclose(fp); +TEST(tool_query_graph_uses_query_max_rows_config_when_omitted) { + char *cache = th_mktempdir("cbm_mcp_query_max_rows_cache"); + ASSERT_NOT_NULL(cache); + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_QUERY_MAX_ROWS, "2"), 0); - /* Create server and register the project */ cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - cbm_store_upsert_project(st, "test-adr-uaf", tmp_dir); - cbm_mcp_server_set_project(srv, "test-adr-uaf"); - /* Call manage_adr via full JSON-RPC path to exercise cbm_jsonrpc_format_response. - * The bug: free(buf) before yy_doc_to_str causes garbage JSON; format_response - * then fails to parse the result and omits the "result" field entirely. */ + const char *proj = "query-max-rows-config"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-max-rows-config"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + for (int i = 0; i < 4; i++) { + char name[CBM_SZ_64]; + char qn[CBM_SZ_128]; + int n = snprintf(name, sizeof(name), "ConfigLimitedFn%d", i); + ASSERT(n >= 0 && (size_t)n < sizeof(name)); + n = snprintf(qn, sizeof(qn), "query.max.ConfigLimitedFn%d", i); + ASSERT(n >= 0 && (size_t)n < sizeof(qn)); + cbm_node_t fn = {.project = proj, + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "src/main.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &fn), 0); + } + char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\"," - "\"arguments\":{\"project\":\"test-adr-uaf\",\"mode\":\"get\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":14,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-max-rows-config\"," + "\"query\":\"MATCH (f:Function) RETURN f.name\"," + "\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - /* JSON-RPC response must include a "result" field (absent when use-after-free) */ - ASSERT_NOT_NULL(strstr(resp, "\"result\"")); - /* ADR content must appear in response */ - ASSERT_NOT_NULL(strstr(resp, "PURPOSE")); - /* Must not be an error */ - ASSERT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + int hits = 0; + const char *p = inner; + while ((p = strstr(p, "ConfigLimitedFn")) != NULL) { + hits++; + p += strlen("ConfigLimitedFn"); + } + ASSERT_EQ(hits, 2); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":true")); + ASSERT_NOT_NULL(strstr(inner, "query_max_rows returned a complete prefix")); + + free(inner); free(resp); - /* Clean up */ + /* The configured server cap is authoritative; query text may request a + * smaller LIMIT but cannot expand the response beyond query_max_rows. */ + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":15,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-max-rows-config\"," + "\"query\":\"MATCH (f:Function) RETURN f.name LIMIT 4\"," + "\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + hits = 0; + p = inner; + while ((p = strstr(p, "ConfigLimitedFn")) != NULL) { + hits++; + p += strlen("ConfigLimitedFn"); + } + ASSERT_EQ(hits, 2); + ASSERT_NOT_NULL(strstr(inner, "\"truncated\":true")); + + free(inner); + free(resp); cbm_mcp_server_free(srv); - remove(adr_path); - rmdir(adr_dir); - rmdir(tmp_dir); + cbm_config_close(cfg); + th_cleanup(cache); PASS(); } -/* issue #256: manage_adr (MCP) and the UI /api/adr endpoints must share ONE - * backend. A manage_adr(update) write must be readable via cbm_store_adr_get - * (the exact API the UI's /api/adr GET uses). */ -TEST(tool_manage_adr_unified_backend_issue256) { +TEST(tool_query_graph_fails_loudly_when_working_row_budget_is_exhausted) { + char *cache = th_mktempdir("cbm_mcp_query_working_rows_cache"); + ASSERT_NOT_NULL(cache); + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_QUERY_MAX_ROWS, "1"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_QUERY_MAX_WORKING_ROWS, "2"), 0); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - cbm_store_upsert_project(st, "adr-unify", "/tmp/adr-unify"); - cbm_mcp_server_set_project(srv, "adr-unify"); - /* Write via the MCP tool. */ - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":120,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," - "\"mode\":\"update\",\"content\":\"## PURPOSE\\nUnified ADR backend.\\n\"}}}"); + const char *proj = "query-working-rows-config"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-working-rows-config"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + for (int i = 0; i < 2; i++) { + char name[CBM_SZ_64]; + char qn[CBM_SZ_128]; + int n = snprintf(name, sizeof(name), "WorkingLimitedFn%d", i); + ASSERT(n >= 0 && (size_t)n < sizeof(name)); + n = snprintf(qn, sizeof(qn), "query.working.WorkingLimitedFn%d", i); + ASSERT(n >= 0 && (size_t)n < sizeof(qn)); + cbm_node_t fn = {.project = proj, + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "src/main.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &fn), 0); + } + + const char *request = + "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\",\"arguments\":{" + "\"project\":\"query-working-rows-config\"," + "\"query\":\"MATCH (a:Function) MATCH (b:Function) RETURN a.name, b.name\"}}}"; + char *resp = cbm_mcp_server_handle(srv, request); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + if (!strstr(inner, "working-row budget (2)")) { + FAIL(inner); + } + ASSERT_NOT_NULL(strstr(inner, "raise query_max_working_rows")); + free(inner); free(resp); - /* Read DIRECTLY via the store API the UI /api/adr uses — must see it. */ - cbm_adr_t adr; - memset(&adr, 0, sizeof(adr)); - ASSERT_EQ(cbm_store_adr_get(st, "adr-unify", &adr), CBM_STORE_OK); - ASSERT_NOT_NULL(adr.content); - ASSERT_NOT_NULL(strstr(adr.content, "Unified ADR backend.")); - cbm_store_adr_free(&adr); - - /* And manage_adr(get) round-trips the same content. */ - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":121,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," - "\"mode\":\"get\"}}}"); + /* Reaching the budget exactly is complete, so it must remain successful. + * The independent output cap still shapes the response down to one row. */ + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_QUERY_MAX_WORKING_ROWS, "4"), 0); + resp = cbm_mcp_server_handle(srv, request); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend.")); ASSERT_NULL(strstr(resp, "\"isError\":true")); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "WorkingLimitedFn")); + free(inner); free(resp); cbm_mcp_server_free(srv); + cbm_config_close(cfg); + th_cleanup(cache); PASS(); } -TEST(tool_manage_adr_rejects_removed_sections_argument) { +TEST(tool_query_graph_warns_on_stale_route_view) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); cbm_store_t *st = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(st); - ASSERT_EQ(cbm_store_upsert_project(st, "adr-sections-guard", "/tmp/adr-sections-guard"), - CBM_STORE_OK); - cbm_mcp_server_set_project(srv, "adr-sections-guard"); - ASSERT_EQ(cbm_store_adr_store(st, "adr-sections-guard", "## PURPOSE\nOriginal ADR.\n"), - CBM_STORE_OK); - mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + const char *proj = "query-route-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-route-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":122,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\",\"arguments\":{" - "\"project\":\"adr-sections-guard\",\"mode\":\"update\"," - "\"sections\":[\"PURPOSE\"],\"content\":\"## PURPOSE\\nReplacement ADR.\\n\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":114,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-route-stale\"," + "\"query\":\"MATCH (r:Route) RETURN r.name LIMIT 5\",\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "invalid_arguments")); - ASSERT_NOT_NULL(strstr(resp, "No ADR write was performed")); - ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); - free(resp); - ASSERT_EQ(probe.begin_count, 0); - ASSERT_EQ(probe.end_count, 0); - - cbm_adr_t adr; - memset(&adr, 0, sizeof(adr)); - ASSERT_EQ(cbm_store_adr_get(st, "adr-sections-guard", &adr), CBM_STORE_OK); - ASSERT_STR_EQ(adr.content, "## PURPOSE\nOriginal ADR.\n"); - cbm_store_adr_free(&adr); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "routes derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_ROUTES)); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -TEST(tool_manage_adr_mutation_guard_balances_success) { - const char *project = "guard-adr-success"; +TEST(tool_query_graph_reports_dirty_metadata_as_canonical_only) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-success"), CBM_STORE_OK); - cbm_mcp_server_set_project(srv, project); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-dirty-metadata"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-dirty-metadata"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_node_t node = {.project = proj, + .label = "Function", + .name = "QueryStillVisible", + .qualified_name = "query.dirty.QueryStillVisible", + .file_path = "src/query_dirty.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "src/query_dirty.c", + .observed_hash = "query-dirty-hash", + .observed_generation = 11, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); - char *resp = cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"guard-adr-success\",\"mode\":\"update\"," - "\"content\":\"## PURPOSE\\nGuarded ADR.\\n\"}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":147,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-dirty-metadata\"," + "\"query\":\"MATCH (f:Function) RETURN f.name LIMIT 5\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); - ASSERT_EQ(probe.begin_count, 1); - ASSERT_EQ(probe.end_count, 1); - ASSERT_STR_EQ(probe.begin_projects[0], project); - ASSERT_STR_EQ(probe.end_projects[0], project); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_TRUE(inner[0] != '\0' && inner[0] != '{'); + ASSERT_NOT_NULL(strstr(inner, "QueryStillVisible")); + ASSERT_NOT_NULL(strstr(inner, "warnings")); + ASSERT_NOT_NULL(strstr(inner, "query_graph reads canonical graph rows")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "canonical_only")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + free(inner); + free(resp); cbm_mcp_server_free(srv); PASS(); } -/* ADR reads must not wait behind the same project's mutation lease. A reindex - * can be expensive; existing SQLite data is a stable query snapshot, so get - * and sections must not invoke the blocking guard. */ -TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard) { - const char *project = "guard-adr-read"; +TEST(tool_query_graph_uses_ready_overlay_for_node_only_query) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-read"), CBM_STORE_OK); - ASSERT_EQ( - cbm_store_adr_store(store, project, "## PURPOSE\nNonblocking read.\n\n## STACK\nC.\n"), - CBM_STORE_OK); - cbm_mcp_server_set_project(srv, project); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-overlay-canonical-only"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-overlay-canonical-only"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldVisibleInCypher", + .qualified_name = "query.overlay.OldVisibleInCypher", + .file_path = "src/main.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_fn), 0); - char *get_response = - cbm_mcp_handle_tool(srv, "manage_adr", "{\"project\":\"guard-adr-read\",\"mode\":\"get\"}"); - char *sections_response = cbm_mcp_handle_tool( - srv, "manage_adr", "{\"project\":\"guard-adr-read\",\"mode\":\"sections\"}"); - bool get_returned_adr = get_response && strstr(get_response, "Nonblocking read.") && - !strstr(get_response, "\"isError\":true"); - bool sections_returned_adr = sections_response && strstr(sections_response, "## PURPOSE") && - strstr(sections_response, "## STACK") && - !strstr(sections_response, "\"isError\":true"); + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_fn = {.project = proj, + .label = "Function", + .name = "FreshHiddenFromCypher", + .qualified_name = "query.overlay.FreshHiddenFromCypher", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &new_fn, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - free(get_response); - free(sections_response); - cbm_mcp_server_free(srv); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":149,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-canonical-only\"," + "\"query\":\"MATCH (f:Function) RETURN f.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_TRUE(inner[0] != '\0' && inner[0] != '{'); + ASSERT_NULL(strstr(inner, "OldVisibleInCypher")); + ASSERT_NOT_NULL(strstr(inner, "FreshHiddenFromCypher")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_TRUE(has_freshness_integer(inner, "active_file_tombstones", 1)); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); - ASSERT_TRUE(get_returned_adr); - ASSERT_TRUE(sections_returned_adr); - ASSERT_EQ(probe.begin_count, 0); - ASSERT_EQ(probe.end_count, 0); + free(inner); + free(resp); + cbm_mcp_server_free(srv); PASS(); } -TEST(tool_manage_adr_read_missing_store_skips_mutation_guard) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-adr-guard-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); - } - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - const char *project = "guard-adr-missing"; +TEST(tool_query_graph_uses_additive_overlay_without_tombstone) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-overlay-additive"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-overlay-additive"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - char *resp = cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"guard-adr-missing\",\"mode\":\"get\"}"); + cbm_node_t stable_fn = {.project = proj, + .label = "Function", + .name = "StableVisibleInCypher", + .qualified_name = "query.overlay.StableVisibleInCypher", + .file_path = "include/shared.h"}; + ASSERT_GT(cbm_store_upsert_node(st, &stable_fn), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_fn = {.project = proj, + .label = "Function", + .name = "FreshAdditiveCypher", + .qualified_name = "query.overlay.FreshAdditiveCypher", + .file_path = "include/shared.h", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "include/shared.h", + .generation = 1, + .nodes = &fresh_fn, + .node_count = 1}; + const cbm_store_file_delta_t *deltas[] = {&delta}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta_additions_batch(st, deltas, 1, + overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":152,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-additive\"," + "\"query\":\"MATCH (f:Function) RETURN f.name LIMIT 5\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed")); - ASSERT_EQ(probe.begin_count, 0); - ASSERT_EQ(probe.end_count, 0); - free(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "StableVisibleInCypher")); + ASSERT_NOT_NULL(strstr(inner, "FreshAdditiveCypher")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_TRUE(has_freshness_integer(inner, "active_file_tombstones", 0)); + ASSERT_TRUE(has_freshness_integer(inner, "overlay_owned_nodes_visible", 1)); + free(inner); + free(resp); cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - cbm_rmdir(cache); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); PASS(); } -TEST(tool_manage_adr_legacy_migration_tries_without_blocking) { - const char *project = "guard-adr-legacy"; - char root[256]; - char cache[256]; - snprintf(root, sizeof(root), "%s/cbm-adr-legacy-XXXXXX", cbm_tmpdir()); - snprintf(cache, sizeof(cache), "%s/cbm-adr-legacy-cache-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(root)); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); +TEST(tool_query_graph_uses_active_relationship_query_with_ready_overlay) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-overlay-rel-active"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-overlay-rel-active"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0); + cbm_node_t old_src = {.project = proj, + .label = "Function", + .name = "OldSource", + .qualified_name = "query.overlay.OldSource", + .file_path = "src/main.c"}; + cbm_node_t old_dst = {.project = proj, + .label = "Function", + .name = "OldTarget", + .qualified_name = "query.overlay.OldTarget", + .file_path = "src/target.c"}; + int64_t old_src_id = cbm_store_upsert_node(st, &old_src); + int64_t old_dst_id = cbm_store_upsert_node(st, &old_dst); + ASSERT_GT(old_src_id, 0); + ASSERT_GT(old_dst_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_src_id, + .target_id = old_dst_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_src = {.project = proj, + .label = "Function", + .name = "FreshSource", + .qualified_name = "query.overlay.FreshSource", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "query.overlay.FreshSource", + .target_qn = "query.overlay.OldTarget", + .type = "CALLS", + .properties_json = "{\"confidence\":0.9}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &new_src, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - char adr_dir[CBM_SZ_1K]; - char adr_path[CBM_SZ_1K]; - snprintf(adr_dir, sizeof(adr_dir), "%s/.codebase-memory", root); - snprintf(adr_path, sizeof(adr_path), "%s/adr.md", adr_dir); - ASSERT_EQ(cbm_mkdir(adr_dir), 0); - FILE *fp = cbm_fopen(adr_path, "w"); - ASSERT_NOT_NULL(fp); - ASSERT_TRUE(fputs("## PURPOSE\nLegacy ADR.\n", fp) >= 0); - ASSERT_EQ(fclose(fp), 0); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":150,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-rel-active\"," + "\"query\":\"MATCH (f:Function)-[r:CALLS]->(g:Function) " + "RETURN f.name, g.name, r.confidence LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshSource")); + ASSERT_NOT_NULL(strstr(inner, "OldTarget")); + ASSERT_NULL(strstr(inner, "OldSource")); + ASSERT_NOT_NULL(strstr(inner, "\"0.9\"")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - cbm_store_t *writer = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(writer); - ASSERT_EQ(cbm_store_upsert_project(writer, project, root), CBM_STORE_OK); - cbm_store_close(writer); + free(inner); + free(resp); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":151,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-rel-active\"," + "\"query\":\"MATCH (f:Function) WHERE f.name = \\\"FreshSource\\\" " + "OPTIONAL MATCH (f)-[:CALLS]->(g:Function) RETURN f.name, g.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshSource")); + ASSERT_NOT_NULL(strstr(inner, "OldTarget")); + ASSERT_NULL(strstr(inner, "OldSource")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); + free(inner); + free(resp); - mcp_mutation_guard_probe_t probe = {.deny_try_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); - cbm_mcp_server_set_project_mutation_try_guard(srv, mcp_mutation_guard_probe_try_begin); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":152,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-rel-active\"," + "\"query\":\"MATCH (g:Function) WHERE g.name = \\\"OldTarget\\\" " + "OPTIONAL MATCH (f:Function)-[:CALLS]->(g) RETURN f.name, g.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshSource")); + ASSERT_NOT_NULL(strstr(inner, "OldTarget")); + ASSERT_NULL(strstr(inner, "OldSource")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); + free(inner); + free(resp); - char *busy_response = cbm_mcp_handle_tool( - srv, "manage_adr", "{\"project\":\"guard-adr-legacy\",\"mode\":\"get\"}"); - char *migrated_response = cbm_mcp_handle_tool( - srv, "manage_adr", "{\"project\":\"guard-adr-legacy\",\"mode\":\"get\"}"); - /* A successful migration invalidates the request-scoped query store; prove - * persistence through the next public read instead of retaining its former - * borrowed test handle. */ - char *persisted_response = cbm_mcp_handle_tool( - srv, "manage_adr", "{\"project\":\"guard-adr-legacy\",\"mode\":\"get\"}"); - bool busy_read_returned_legacy = busy_response && strstr(busy_response, "Legacy ADR.") && - !strstr(busy_response, "\"isError\":true"); - bool migrated_read_returned_legacy = migrated_response && - strstr(migrated_response, "Legacy ADR.") && - !strstr(migrated_response, "\"isError\":true"); - bool migration_persisted = persisted_response && strstr(persisted_response, "Legacy ADR.") && - !strstr(persisted_response, "\"isError\":true"); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":153,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-rel-active\"," + "\"query\":\"MATCH (g:Function) WHERE g.name = \\\"OldTarget\\\" " + "OPTIONAL MATCH (f:Function)-[:IMPORTS]->(g) RETURN f.name, g.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "OldTarget")); + ASSERT_NULL(strstr(inner, "FreshSource")); + ASSERT_NULL(strstr(inner, "OldSource")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); + free(inner); + free(resp); - free(busy_response); - free(migrated_response); - free(persisted_response); cbm_mcp_server_free(srv); - cbm_unlink(adr_path); - cbm_rmdir(adr_dir); - cbm_rmdir(root); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); + PASS(); +} - ASSERT_TRUE(busy_read_returned_legacy); - ASSERT_TRUE(migrated_read_returned_legacy); - ASSERT_TRUE(migration_persisted); - ASSERT_EQ(probe.begin_count, 0); - ASSERT_EQ(probe.try_begin_count, 2); - ASSERT_EQ(probe.end_count, 1); - ASSERT_STR_EQ(probe.try_begin_projects[0], project); - ASSERT_STR_EQ(probe.try_begin_projects[1], project); - ASSERT_STR_EQ(probe.end_projects[0], project); - PASS(); -} - -/* A raw cbm_mcp_handle_tool() call is still one request lifetime. Cancellation - * published from inside a non-pipeline handler must therefore be accepted, - * observed before the write, and retired at completion so the next raw request - * on the same server is not poisoned. */ -TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean) { - const char *project = "raw-cancel-adr"; - char root[256]; - snprintf(root, sizeof(root), "%s/cbm-mcp-raw-adr-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(root)); +TEST(tool_query_graph_uses_active_variable_length_relationship_query_with_ready_overlay) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - ASSERT_EQ(cbm_store_upsert_project(store, project, root), CBM_STORE_OK); - cbm_mcp_server_set_project(srv, project); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-overlay-rel-var-canonical"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-overlay-rel-var-canonical"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - mcp_mutation_guard_probe_t probe = { - .cancel_on_begin_call = 1, - .cancel_server = srv, - }; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_node_t old_src = {.project = proj, + .label = "Function", + .name = "OldVarSource", + .qualified_name = "query.overlay.OldVarSource", + .file_path = "src/main.c"}; + cbm_node_t old_dst = {.project = proj, + .label = "Function", + .name = "OldVarTarget", + .qualified_name = "query.overlay.OldVarTarget", + .file_path = "src/target.c"}; + int64_t old_src_id = cbm_store_upsert_node(st, &old_src); + int64_t old_dst_id = cbm_store_upsert_node(st, &old_dst); + ASSERT_GT(old_src_id, 0); + ASSERT_GT(old_dst_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_src_id, + .target_id = old_dst_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_src = {.project = proj, + .label = "Function", + .name = "FreshVarSource", + .qualified_name = "query.overlay.FreshVarSource", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "query.overlay.FreshVarSource", + .target_qn = "query.overlay.OldVarTarget", + .type = "CALLS", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &new_src, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - char *cancelled_response = - cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"raw-cancel-adr\",\"mode\":\"update\"," - "\"content\":\"## PURPOSE\\nMUST NOT COMMIT.\\n\"}"); - bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && - strstr(cancelled_response, "\"isError\":true"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":155,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-rel-var-canonical\"," + "\"query\":\"MATCH (f:Function)-[:CALLS*1..2]->(g:Function) " + "RETURN f.name, g.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshVarSource")); + ASSERT_NOT_NULL(strstr(inner, "OldVarTarget")); + ASSERT_NULL(strstr(inner, "OldVarSource")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); - cbm_adr_t cancelled_adr = {0}; - int cancelled_lookup = cbm_store_adr_get(store, project, &cancelled_adr); - if (cancelled_lookup == CBM_STORE_OK) { - cbm_store_adr_free(&cancelled_adr); - } + free(inner); + free(resp); - char *next_response = - cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"raw-cancel-adr\",\"mode\":\"update\"," - "\"content\":\"## PURPOSE\\nClean next request.\\n\"}"); - bool next_response_clean = next_response && strstr(next_response, "updated") && - !strstr(next_response, "cancelled") && - !strstr(next_response, "\"isError\":true"); - cbm_adr_t next_adr = {0}; - int next_lookup = cbm_store_adr_get(store, project, &next_adr); - bool next_write_committed = next_lookup == CBM_STORE_OK && next_adr.content && - strstr(next_adr.content, "Clean next request") && - !strstr(next_adr.content, "MUST NOT COMMIT"); - if (next_lookup == CBM_STORE_OK) { - cbm_store_adr_free(&next_adr); - } + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":156,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-rel-var-canonical\"," + "\"query\":\"MATCH (f:Function)-[:CALLS*1..2]-(g:Function) " + "RETURN f.name, g.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshVarSource")); + ASSERT_NOT_NULL(strstr(inner, "OldVarTarget")); + ASSERT_NULL(strstr(inner, "OldVarSource")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); - free(cancelled_response); - free(next_response); + free(inner); + free(resp); cbm_mcp_server_free(srv); - (void)cbm_rmdir(root); - - ASSERT_TRUE(probe.cancel_attempted); - ASSERT_TRUE(probe.cancel_accepted); - ASSERT_TRUE(cancellation_reported); - ASSERT_EQ(cancelled_lookup, CBM_STORE_NOT_FOUND); - ASSERT_TRUE(next_response_clean); - ASSERT_TRUE(next_write_committed); - ASSERT_EQ(probe.begin_count, 2); - ASSERT_EQ(probe.end_count, 2); - ASSERT_STR_EQ(probe.begin_projects[0], project); - ASSERT_STR_EQ(probe.end_projects[0], project); - ASSERT_STR_EQ(probe.begin_projects[1], project); - ASSERT_STR_EQ(probe.end_projects[1], project); PASS(); } -/* The daemon publishes its transport request before entering MCP dispatch. A - * disconnect in that narrow interval must remain latched through the nested - * raw tool scope instead of being erased at dispatch entry. */ -TEST(tool_outer_request_scope_preserves_predispatch_cancel) { - const char *project = "outer-scope-cancel-adr"; - char root[256]; - (void)snprintf(root, sizeof(root), "%s/cbm-mcp-outer-cancel-XXXXXX", cbm_tmpdir()); - bool root_created = cbm_mkdtemp(root) != NULL; +TEST(tool_query_graph_uses_active_edges_for_degree_and_exists) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - cbm_store_t *store = cbm_mcp_server_store(srv); - bool project_ready = - root_created && store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; - cbm_mcp_server_set_project(srv, project); - bool outer_scope = project_ready && cbm_mcp_server_request_scope_begin(srv); - bool cancel_accepted = outer_scope && cbm_mcp_server_cancel_active(srv); - char *cancelled_response = - cancel_accepted - ? cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"outer-scope-cancel-adr\"," - "\"mode\":\"update\",\"content\":\"MUST NOT COMMIT\"}") - : NULL; - bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && - strstr(cancelled_response, "\"isError\":true"); - cbm_mcp_server_request_scope_end(srv); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-overlay-active-edge-derived"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-overlay-active-edge-derived"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL; - bool next_response_clean = next_response && strstr(next_response, "accepted") && - !strstr(next_response, "cancelled") && - !strstr(next_response, "\"isError\":true"); + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldDerivedSource", + .qualified_name = "query.overlay.OldDerivedSource", + .file_path = "src/main.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_fn), 0); + cbm_node_t stable_target = {.project = proj, + .label = "Function", + .name = "StableTarget", + .qualified_name = "query.overlay.StableTarget", + .file_path = "src/target.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &stable_target), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_fn = {.project = proj, + .label = "Function", + .name = "FreshDerivedSource", + .qualified_name = "query.overlay.FreshDerivedSource", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t fresh_edge = {.source_qn = "query.overlay.FreshDerivedSource", + .target_qn = "query.overlay.StableTarget", + .type = "CALLS", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &new_fn, + .node_count = 1, + .edges = &fresh_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - free(cancelled_response); - free(next_response); - cbm_mcp_server_free(srv); - (void)cbm_rmdir(root); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":151,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-active-edge-derived\"," + "\"query\":\"MATCH (f:Function) WHERE f.name = \\\"FreshDerivedSource\\\" " + "RETURN f.out_degree, f.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshDerivedSource")); + ASSERT_NULL(strstr(inner, "OldDerivedSource")); + ASSERT_NOT_NULL(strstr(inner, "\"1\"")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); + free(inner); + free(resp); - ASSERT_TRUE(root_created); - ASSERT_NOT_NULL(srv); - ASSERT_TRUE(project_ready); - ASSERT_TRUE(outer_scope); - ASSERT_TRUE(cancel_accepted); - ASSERT_TRUE(cancellation_reported); - ASSERT_TRUE(next_response_clean); + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":153,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-active-edge-derived\"," + "\"query\":\"MATCH (f:Function) WHERE EXISTS { (f)-[:CALLS]->() } " + "RETURN f.name LIMIT 5\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshDerivedSource")); + ASSERT_NULL(strstr(inner, "OldDerivedSource")); + ASSERT_NULL(strstr(inner, "StableTarget")); + ASSERT_TRUE(has_freshness_string(inner, "read_model", "overlay_active_nodes")); + ASSERT_NOT_NULL(strstr(inner, "active edge-derived predicates")); + free(inner); + free(resp); + + cbm_mcp_server_free(srv); PASS(); } -/* Publish cancellation from the local index mutation guard: the request scope - * must already be active, and the cancellation must either stop before - * pipeline admission or remain set through pipeline binding. No project DB may - * be published, and the following request must start with a clean token. */ -TEST(tool_index_repository_early_raw_cancel_survives_index_entry) { - char cache[256]; - char repo[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-raw-index-cache-XXXXXX", cbm_tmpdir()); - snprintf(repo, sizeof(repo), "%s/cbm-mcp-raw-index-repo-XXXXXX", cbm_tmpdir()); - bool cache_created = cbm_mkdtemp(cache) != NULL; - bool repo_created = cbm_mkdtemp(repo) != NULL; - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - if (cache_created) { - cbm_setenv("CBM_CACHE_DIR", cache, 1); - } - - char *project = repo_created ? cbm_project_name_from_path(repo) : NULL; - cbm_mcp_server_t *srv = - cache_created && repo_created && project ? cbm_mcp_server_new(NULL) : NULL; - mcp_mutation_guard_probe_t probe = { - .cancel_on_begin_call = 1, - .cancel_server = srv, - }; - if (srv) { - cbm_mcp_server_set_background_tasks(srv, false); - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); - } +TEST(tool_query_graph_keeps_id_query_canonical_with_ready_overlay) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "query-overlay-id-canonical"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-overlay-id-canonical"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - char args[CBM_SZ_1K]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo); - char *cancelled_response = srv ? cbm_mcp_handle_tool(srv, "index_repository", args) : NULL; - bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && - strstr(cancelled_response, "\"isError\":true"); + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldIdSource", + .qualified_name = "query.overlay.OldIdSource", + .file_path = "src/main.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_fn), 0); - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing-project"); - bool no_project_published = !cbm_file_exists(db_path); + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_fn = {.project = proj, + .label = "Function", + .name = "FreshIdSource", + .qualified_name = "query.overlay.FreshIdSource", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &new_fn, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); - char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL; - bool next_response_clean = next_response && strstr(next_response, "accepted") && - !strstr(next_response, "cancelled") && - !strstr(next_response, "\"isError\":true"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":154,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-overlay-id-canonical\"," + "\"query\":\"MATCH (f:Function) RETURN id(f), f.name LIMIT 5\"," + "\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "OldIdSource")); + ASSERT_NULL(strstr(inner, "FreshIdSource")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"canonical_only\"")); + ASSERT_NOT_NULL(strstr(inner, "id() semantics")); - free(cancelled_response); - free(next_response); + free(inner); + free(resp); cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - if (cache_created) { - (void)cbm_rmdir(cache); - } - if (repo_created) { - (void)cbm_rmdir(repo); - } - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - free(project); + PASS(); +} - ASSERT_TRUE(cache_created); - ASSERT_TRUE(repo_created); +TEST(tool_query_graph_warns_when_broad_query_returns_stale_route) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(probe.cancel_attempted); - ASSERT_TRUE(probe.cancel_accepted); - ASSERT_TRUE(cancellation_reported); - ASSERT_EQ(probe.begin_count, 1); - ASSERT_EQ(probe.end_count, 1); - ASSERT_TRUE(no_project_published); - ASSERT_TRUE(next_response_clean); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "query-route-result-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-route-result-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + cbm_node_t route = {.project = proj, + .label = "Route", + .name = "/api/status", + .qualified_name = "__route__GET__/api/status", + .file_path = "src/status.ts"}; + ASSERT_GT(cbm_store_upsert_node(st, &route), 0); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":115,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-route-result-stale\"," + "\"query\":\"MATCH (n) RETURN n.label LIMIT 5\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "routes derived view is stale")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); PASS(); } -static bool mcp_cross_repo_create_project_store(const char *cache, const char *project, - const char *root_path) { - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - cbm_store_t *store = cbm_store_open_path(db_path); - if (!store) { - return false; - } - bool created = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK; - cbm_store_close(store); - return created; -} - -/* Seed exactly one HTTP route match without invoking the indexing pipeline. - * This keeps the duplicate-target regression fast and makes a doubled result - * count observable instead of relying on an empty (zero-edge) scan. */ -static bool mcp_cross_repo_seed_http_match(const char *cache, const char *source_project, - const char *target_project, const char *root_path) { - char source_path[CBM_SZ_1K]; - char target_path[CBM_SZ_1K]; - snprintf(source_path, sizeof(source_path), "%s/%s.db", cache, source_project); - snprintf(target_path, sizeof(target_path), "%s/%s.db", cache, target_project); - - cbm_store_t *source = cbm_store_open_path(source_path); - cbm_store_t *target = cbm_store_open_path(target_path); - if (!source || !target) { - cbm_store_close(source); - cbm_store_close(target); - return false; - } - - bool ok = cbm_store_upsert_project(source, source_project, root_path) == CBM_STORE_OK && - cbm_store_upsert_project(target, target_project, root_path) == CBM_STORE_OK; +TEST(tool_query_graph_warns_on_stale_semantic_edges) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - cbm_node_t caller = {.project = source_project, - .label = "Function", - .name = "call_once", - .qualified_name = "cross.source.call_once", - .file_path = "client.c", - .start_line = 1, - .end_line = 2}; - cbm_node_t local_route = {.project = source_project, - .label = "Route", - .name = "GET /dedupe", - .qualified_name = "__route__GET__/dedupe", - .file_path = "client.c", - .start_line = 3, - .end_line = 3}; - int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; - int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; - cbm_edge_t http_call = {.project = source_project, - .source_id = caller_id, - .target_id = local_route_id, - .type = "HTTP_CALLS", - .properties_json = "{\"url_path\":\"/dedupe\",\"method\":\"GET\"}"}; - ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + const char *proj = "query-semantic-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-semantic-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); - cbm_node_t target_route = {.project = target_project, - .label = "Route", - .name = "GET /dedupe", - .qualified_name = "__route__GET__/dedupe", - .file_path = "server.c", - .start_line = 3, - .end_line = 3}; - cbm_node_t handler = {.project = target_project, - .label = "Function", - .name = "handle_once", - .qualified_name = "cross.target.handle_once", - .file_path = "server.c", - .start_line = 1, - .end_line = 2}; - int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; - int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; - cbm_edge_t handles = {.project = target_project, - .source_id = handler_id, - .target_id = target_route_id, - .type = "HANDLES"}; - ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":115,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-semantic-stale\"," + "\"query\":\"MATCH (a)-[:SEMANTICALLY_RELATED]->(b) " + "RETURN a.name, b.name LIMIT 5\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "semantic_edges derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES)); - cbm_store_close(source); - cbm_store_close(target); - return ok; + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); } -TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds) { - char repo[256]; - snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-guard-XXXXXX"); - if (!cbm_mkdtemp(repo)) { - PASS(); - } - +TEST(tool_query_graph_warns_on_stale_similarity_edges) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - mcp_mutation_guard_probe_t probe = {.deny_begin_call = 3}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + const char *proj = "query-similarity-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/query-similarity-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); - char args[CBM_SZ_2K]; - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"zzz-target\",\"000-target\",\"zzz-target\"]}", - repo); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":116,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{\"project\":\"query-similarity-stale\"," + "\"query\":\"MATCH (a)-[:SIMILAR_TO]->(b) RETURN a.name, b.name LIMIT 5\"," + "\"format\":\"json\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "blocked")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "semantic_edges derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES)); - /* The source plus two unique targets are acquired in lexical order. The - * third acquisition is denied, so only the first two are unwound. */ - ASSERT_EQ(probe.begin_count, 3); - ASSERT_TRUE(strcmp(probe.begin_projects[0], probe.begin_projects[1]) < 0); - ASSERT_TRUE(strcmp(probe.begin_projects[1], probe.begin_projects[2]) < 0); - int low_target_count = 0; - int high_target_count = 0; - for (int i = 0; i < probe.begin_count; i++) { - low_target_count += strcmp(probe.begin_projects[i], "000-target") == 0; - high_target_count += strcmp(probe.begin_projects[i], "zzz-target") == 0; - } - ASSERT_EQ(low_target_count, 1); - ASSERT_EQ(high_target_count, 1); - ASSERT_EQ(probe.end_count, 2); - ASSERT_STR_EQ(probe.end_projects[0], probe.begin_projects[1]); - ASSERT_STR_EQ(probe.end_projects[1], probe.begin_projects[0]); + free(inner); free(resp); - cbm_mcp_server_free(srv); - cbm_rmdir(repo); PASS(); } -static unsigned char mcp_test_ascii_casefold(unsigned char ch) { - return ch >= 'A' && ch <= 'Z' ? (unsigned char)(ch + ('a' - 'A')) : ch; -} +TEST(tool_index_status_no_project) { + cbm_mcp_server_t *srv = setup_mcp_with_data(); -static bool mcp_test_project_keys_equivalent(const char *left, const char *right) { - if (!left || !right) { - return left == right; - } - while (*left && *right) { - if (mcp_test_ascii_casefold((unsigned char)*left) != - mcp_test_ascii_casefold((unsigned char)*right)) { - return false; - } - left++; - right++; - } - return *left == *right; -} + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":15,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\",\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + /* Should return error or empty status */ + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + free(resp); -/* Project-lock keys ASCII-fold A-Z, so case aliases must be one lease here too. - * Otherwise Foo + foo self-deadlocks, and two requests whose raw strcmp order - * differs can acquire the same OS locks in opposite (ABBA) order. Keep the - * original spellings: folding is only the comparison key, not a lookup value. */ -TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) { - char repo[256]; - snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-case-guard-XXXXXX"); - if (!cbm_mkdtemp(repo)) { - PASS(); - } + cbm_mcp_server_free(srv); + PASS(); +} +TEST(status_surfaces_share_exact_graph_stats) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); - mcp_mutation_guard_probe_t first = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &first); - char first_args[CBM_SZ_2K]; - snprintf(first_args, sizeof(first_args), - "{\"repo_path\":\"%s\",\"name\":\"Zulu\"," - "\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"Foo\",\"foo\",\"Alpha\"]}", - repo); - char *first_resp = cbm_mcp_handle_tool(srv, "index_repository", first_args); - ASSERT_NOT_NULL(first_resp); - free(first_resp); + const char *project = "status-exact-stats"; + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/status-exact-stats"), CBM_STORE_OK); + cbm_node_t first = {.project = project, + .label = "Function", + .name = "first", + .qualified_name = "status.first"}; + cbm_node_t second = {.project = project, + .label = "Function", + .name = "second", + .qualified_name = "status.second"}; + int64_t first_id = cbm_store_upsert_node(store, &first); + int64_t second_id = cbm_store_upsert_node(store, &second); + ASSERT_GT(first_id, 0); + ASSERT_GT(second_id, 0); + cbm_edge_t edge = { + .project = project, .source_id = first_id, .target_id = second_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(store, &edge), 0); + ASSERT_EQ(cbm_store_exec(store, + "INSERT INTO pagerank(project,node_id,rank,computed_at) VALUES" + "('status-exact-stats',1,0.6,'2026-07-31T21:00:00Z')," + "('status-exact-stats',2,0.4,'2026-07-31T21:00:00Z');"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); - mcp_mutation_guard_probe_t second = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &second); - char second_args[CBM_SZ_2K]; - snprintf(second_args, sizeof(second_args), - "{\"repo_path\":\"%s\",\"name\":\"zULU\"," - "\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"foo\",\"ALPHA\",\"FOO\"]}", - repo); - char *second_resp = cbm_mcp_handle_tool(srv, "index_repository", second_args); - ASSERT_NOT_NULL(second_resp); - free(second_resp); + /* Unfinalized writes take the automatic exact-scan path. */ + char *response = cbm_mcp_handle_tool(srv, "index_status", + "{\"project\":\"status-exact-stats\"}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"nodes\":2")); + ASSERT_NOT_NULL(strstr(inner, "\"edges\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"ranked_nodes\":2")); + ASSERT_NOT_NULL(strstr(inner, "\"computed_at\":\"2026-07-31T21:00:00Z\"")); + free(inner); + free(response); - ASSERT_EQ(first.begin_count, 3); - ASSERT_EQ(first.end_count, 3); - ASSERT_EQ(second.begin_count, 3); - ASSERT_EQ(second.end_count, 3); - for (int i = 0; i < 3; i++) { - ASSERT_TRUE( - mcp_test_project_keys_equivalent(first.begin_projects[i], second.begin_projects[i])); - ASSERT_TRUE( - mcp_test_project_keys_equivalent(first.end_projects[i], first.begin_projects[2 - i])); - ASSERT_TRUE( - mcp_test_project_keys_equivalent(second.end_projects[i], second.begin_projects[2 - i])); - } - ASSERT_STR_EQ(first.begin_projects[0], "Alpha"); - ASSERT_STR_EQ(first.begin_projects[1], "Foo"); - ASSERT_STR_EQ(first.begin_projects[2], "Zulu"); - ASSERT_STR_EQ(second.begin_projects[0], "ALPHA"); - ASSERT_STR_EQ(second.begin_projects[1], "FOO"); - ASSERT_STR_EQ(second.begin_projects[2], "zULU"); + /* Finalized O(log P) reads preserve the existing resource field names. */ + ASSERT_EQ(cbm_store_refresh_project_graph_stats(store), CBM_STORE_OK); + response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":151,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\\\"nodes\\\":2")); + ASSERT_NOT_NULL(strstr(response, "\\\"edges\\\":1")); + ASSERT_NOT_NULL(strstr(response, "\\\"ranked_nodes\\\":2")); + ASSERT_NOT_NULL(strstr(response, + "\\\"pagerank_computed_at\\\":\\\"2026-07-31T21:00:00Z\\\"")); + free(response); + + response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":152,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://architecture\"}}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\\\"total_nodes\\\":2")); + ASSERT_NOT_NULL(strstr(response, "\\\"total_edges\\\":1")); + free(response); cbm_mcp_server_free(srv); - cbm_rmdir(repo); PASS(); } -/* A wildcard means "all projects" and therefore cannot be combined with a - * named target. Accepting the mixed form both obscures caller intent and lets - * the cross-repo pass create/use a literal "*.db" target on POSIX. Validation - * must happen before any project mutation lease is acquired. */ -TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-wildcard-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - char *project = cbm_project_name_from_path(cache); - ASSERT_NOT_NULL(project); +/* Reproduce the exact-file false negative in the current Read hook: index_status + * intentionally caps each coverage category at 500 entries, so a later path is + * absent even though the authoritative index_coverage table contains it. The + * targeted coverage tool must query that table rather than scan the capped + * presentation response. */ +TEST(tool_check_index_coverage_finds_path_beyond_status_cap) { + enum { ROW_COUNT = 502 }; cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + const char *project = "coverage-cap-regression"; + ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/coverage-cap-regression"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); - char args[CBM_SZ_2K]; - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"*\",\"named-target\"]}", - cache); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - bool rejected = resp && strstr(resp, "\"isError\":true") != NULL; - bool explained = resp && strstr(resp, "target_projects") && strstr(resp, "*") && - (strstr(resp, "only") || strstr(resp, "combin")); - int begin_count = probe.begin_count; - int end_count = probe.end_count; + char (*paths)[64] = calloc(ROW_COUNT, sizeof(*paths)); + cbm_coverage_row_t *rows = calloc(ROW_COUNT, sizeof(*rows)); + ASSERT_NOT_NULL(paths); + ASSERT_NOT_NULL(rows); + for (int i = 0; i < ROW_COUNT; i++) { + snprintf(paths[i], sizeof(paths[i]), "src/partial-%04d.c", i); + rows[i].rel_path = paths[i]; + rows[i].kind = "parse_partial"; + rows[i].detail = i == ROW_COUNT - 1 ? "777-790" : "1-2"; + ASSERT_EQ(cbm_store_upsert_file_hash(st, project, paths[i], "fixture", i + 1, 10), + CBM_STORE_OK); + } + ASSERT_EQ(cbm_store_coverage_replace(st, project, rows, ROW_COUNT), CBM_STORE_OK); - free(resp); - cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - cleanup_project_db(cache, "*"); - cleanup_project_db(cache, "named-target"); - free(project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); + char *status = + cbm_mcp_handle_tool(srv, "index_status", "{\"project\":\"coverage-cap-regression\"}"); + ASSERT_NOT_NULL(status); + char *status_inner = extract_text_content(status); + ASSERT_NOT_NULL(status_inner); + ASSERT_NOT_NULL(strstr(status_inner, "\"truncated\":true")); + ASSERT_NULL(strstr(status_inner, "src/partial-0501.c")); + free(status_inner); + free(status); - ASSERT_TRUE(rejected); - ASSERT_TRUE(explained); - ASSERT_EQ(begin_count, 0); - ASSERT_EQ(end_count, 0); + char *coverage = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"coverage-cap-regression\",\"paths\":[\"src/partial-0501.c\"]}"); + ASSERT_NOT_NULL(coverage); + char *coverage_inner = extract_text_content(coverage); + ASSERT_NOT_NULL(coverage_inner); + ASSERT_NOT_NULL(strstr(coverage_inner, "src/partial-0501.c")); + ASSERT_NOT_NULL(strstr(coverage_inner, "\"status\":\"partial\"")); + ASSERT_NOT_NULL(strstr(coverage_inner, "777-790")); + + free(coverage_inner); + free(coverage); + free(rows); + free(paths); + cbm_mcp_server_free(srv); PASS(); } -/* Cancellation can arrive while the final mutation lease is being acquired. - * The cross-repo operation must advertise itself through cancel_active(), - * observe the pending cancellation before doing cross-project writes, and - * unwind every lease it acquired. */ -TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-cancel-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - char *project = cbm_project_name_from_path(cache); - ASSERT_NOT_NULL(project); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - mcp_mutation_guard_probe_t probe = { - .cancel_on_begin_call = 3, - .cancel_server = srv, + ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "main.go", "", 0, 0), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "src/skip.c", "", 0, 0), CBM_STORE_OK); + cbm_coverage_row_t rows[] = { + {.rel_path = "main.go", .kind = "parse_partial", .detail = "3-4,9"}, + {.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"}, + {.rel_path = "src/skip.c", .kind = "oversized", .detail = "file exceeds cap"}, }; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + ASSERT_EQ(cbm_store_coverage_replace(st, "test-project", rows, 3), CBM_STORE_OK); - char args[CBM_SZ_2K]; - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"000-cancel-target\",\"zzz-cancel-target\"]}", - cache); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - bool response_cancelled = resp && strstr(resp, "cancelled") != NULL; - bool cancel_attempted = probe.cancel_attempted; - bool cancel_accepted = probe.cancel_accepted; - int begin_count = probe.begin_count; - int end_count = probe.end_count; - bool reverse_unwind = begin_count == 3 && end_count == 3 && - strcmp(probe.end_projects[0], probe.begin_projects[2]) == 0 && - strcmp(probe.end_projects[1], probe.begin_projects[1]) == 0 && - strcmp(probe.end_projects[2], probe.begin_projects[0]) == 0; + char *coverage = + cbm_mcp_handle_tool(srv, "check_index_coverage", + "{\"project\":\"test-project\"," + "\"paths\":[\"main.go\",\"generated/pkg/a.c\",\"../escape.c\"]," + "\"scopes\":[\"src\",\"generated\"]}"); + ASSERT_NOT_NULL(coverage); + char *inner = extract_text_content(coverage); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"path\":\"main.go\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"partial\"")); + ASSERT_NOT_NULL(strstr(inner, "\"start\":3")); + ASSERT_NOT_NULL(strstr(inner, "\"end\":4")); + ASSERT_NOT_NULL(strstr(inner, "\"start\":9")); + ASSERT_NOT_NULL(strstr(inner, "generated/pkg/a.c")); + ASSERT_NOT_NULL(strstr(inner, "not_indexed_dir")); + ASSERT_NOT_NULL(strstr(inner, "outside_project")); + ASSERT_NOT_NULL(strstr(inner, "src/skip.c")); + ASSERT_NOT_NULL(strstr(inner, "file exceeds cap")); + ASSERT_NOT_NULL(strstr(inner, "\"requested_scope\":\"src\",\"scope\":\"src\"")); + ASSERT_NOT_NULL(strstr(inner, "\"requested_scope\":\"generated\",\"scope\":\"generated\"")); + ASSERT_NOT_NULL(strstr(inner, "best_effort")); - free(resp); + free(inner); + free(coverage); cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - cleanup_project_db(cache, "000-cancel-target"); - cleanup_project_db(cache, "zzz-cancel-target"); - free(project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - - ASSERT_TRUE(cancel_attempted); - ASSERT_TRUE(cancel_accepted); - ASSERT_TRUE(response_cancelled); - ASSERT_EQ(begin_count, 3); - ASSERT_EQ(end_count, 3); - ASSERT_TRUE(reverse_unwind); + cleanup_snippet_dir(tmp); PASS(); } -/* cbm_store_open_path() creates its path. Cross-repo validation must therefore - * reject an absent source or named target before the matcher opens either one; - * otherwise a typo silently becomes a valid-looking empty project database. */ -TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-missing-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - char *source_project = cbm_project_name_from_path(cache); - ASSERT_NOT_NULL(source_project); - const char *existing_target = "existing-cross-target"; - const char *missing_target = "missing-cross-target"; - ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, existing_target, cache)); - - char source_db_path[CBM_SZ_1K]; - char missing_target_db_path[CBM_SZ_1K]; - snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project); - snprintf(missing_target_db_path, sizeof(missing_target_db_path), "%s/%s.db", cache, - missing_target); - ASSERT_FALSE(cbm_file_exists(source_db_path)); - - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_check_index_coverage_preserves_multiple_scope_labels) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); - - char args[CBM_SZ_2K]; - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"%s\"]}", - cache, existing_target); - char *source_resp = cbm_mcp_handle_tool(srv, "index_repository", args); - bool source_failed = source_resp && strstr(source_resp, "\"isError\":true"); - bool source_reported = - source_resp && (strstr(source_resp, "not indexed") || strstr(source_resp, "not found") || - strstr(source_resp, "missing")); - bool source_ghost_created = cbm_file_exists(source_db_path); - free(source_resp); - cleanup_project_db(cache, source_project); - ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, source_project, cache)); - ASSERT_FALSE(cbm_file_exists(missing_target_db_path)); + char *coverage = cbm_mcp_handle_tool(srv, "check_index_coverage", + "{\"project\":\"test-project\"," + "\"scopes\":[\"alpha/one\",\"bravo/two\",\"charl/tri\"]}"); + ASSERT_NOT_NULL(coverage); + char *inner = extract_text_content(coverage); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *scopes = yyjson_obj_get(yyjson_doc_get_root(doc), "scopes"); + ASSERT_NOT_NULL(scopes); + ASSERT_TRUE(yyjson_is_arr(scopes)); + ASSERT_EQ(yyjson_arr_size(scopes), 3); - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"%s\"]}", - cache, missing_target); - char *target_resp = cbm_mcp_handle_tool(srv, "index_repository", args); - bool target_failed = target_resp && strstr(target_resp, "\"isError\":true"); - bool target_reported = - target_resp && (strstr(target_resp, "not indexed") || strstr(target_resp, "not found") || - strstr(target_resp, "missing")); - bool target_ghost_created = cbm_file_exists(missing_target_db_path); - free(target_resp); + const char *expected[] = {"alpha/one", "bravo/two", "charl/tri"}; + for (size_t i = 0; i < 3; i++) { + yyjson_val *scope = yyjson_obj_get(yyjson_arr_get(scopes, i), "scope"); + ASSERT_NOT_NULL(scope); + ASSERT_TRUE(yyjson_is_str(scope)); + ASSERT_STR_EQ(yyjson_get_str(scope), expected[i]); + } + yyjson_doc_free(doc); + free(inner); + free(coverage); cbm_mcp_server_free(srv); - cleanup_project_db(cache, source_project); - cleanup_project_db(cache, existing_target); - cleanup_project_db(cache, missing_target); - free(source_project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - - ASSERT_TRUE(source_failed); - ASSERT_TRUE(source_reported); - ASSERT_FALSE(source_ghost_created); - ASSERT_TRUE(target_failed); - ASSERT_TRUE(target_reported); - ASSERT_FALSE(target_ghost_created); + cleanup_snippet_dir(tmp); PASS(); } -/* Named targets are a set, not a work list. A duplicate must be leased, - * scanned, and counted once; the fixture provides one real edge so the result - * counters cannot pass vacuously at zero. */ -TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-dedupe-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - char *source_project = cbm_project_name_from_path(cache); - ASSERT_NOT_NULL(source_project); - const char *target_project = "cross-dedupe-target"; - ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); +static int write_coverage_meta(cbm_store_t *store, const char *generation, + const char *recording_status) { + cbm_coverage_meta_t meta = { + .generation = generation, + .index_mode = "fast", + .recorded_at = "2026-07-12T00:00:00Z", + .recording_status = recording_status, + .ignored_files_stored = 0, + .ignored_files_total = 0, + .coverage_version = 1, + .hash_records_complete = true, + }; + return cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta); +} - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(first_response_and_status_resource_share_coverage_generation_state) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_session_project(srv, "test-project"); - char args[CBM_SZ_2K]; - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"%s\",\"%s\"]}", - cache, target_project, target_project); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - bool succeeded = resp && strstr(resp, "\"isError\":true") == NULL; - bool scanned_once = response_contains_json_fragment(resp, "\"projects_scanned\":1"); - bool counted_once = response_contains_json_fragment(resp, "\"cross_http_calls\":1") && - response_contains_json_fragment(resp, "\"total_cross_edges\":1"); + cbm_project_t project = {0}; + ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK); + ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK); + cbm_project_free_fields(&project); - char source_db_path[CBM_SZ_1K]; - char target_db_path[CBM_SZ_1K]; - snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project); - snprintf(target_db_path, sizeof(target_db_path), "%s/%s.db", cache, target_project); - cbm_store_t *source = cbm_store_open_path_query(source_db_path); - cbm_store_t *target = cbm_store_open_path_query(target_db_path); - int source_cross_edges = - source ? cbm_store_count_edges_by_type(source, source_project, "CROSS_HTTP_CALLS") : -1; - int target_cross_edges = - target ? cbm_store_count_edges_by_type(target, target_project, "CROSS_HTTP_CALLS") : -1; - cbm_store_close(source); - cbm_store_close(target); + char *response = cbm_mcp_handle_tool( + srv, "trace_path", + "{\"project\":\"test-project\",\"function_name\":\"HandleRequest\"," + "\"format\":\"json\"}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"coverage\":{\"status\":\"current\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status_scope\":\"published_generation\"")); + ASSERT_NOT_NULL(strstr(inner, "\"live_source_freshness\":\"not_evaluated\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recording_status\":\"complete\"")); + ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"hash_records_complete\":true")); + ASSERT_NOT_NULL(strstr(inner, "check_index_coverage")); + free(inner); + free(response); - free(resp); - cbm_mcp_server_free(srv); - cleanup_project_db(cache, source_project); - cleanup_project_db(cache, target_project); - free(source_project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); + response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":451,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\\\"coverage\\\":{\\\"status\\\":\\\"current\\\"")); + ASSERT_NOT_NULL(strstr(response, "\\\"status_scope\\\":\\\"published_generation\\\"")); + ASSERT_NOT_NULL(strstr(response, "\\\"live_source_freshness\\\":\\\"not_evaluated\\\"")); + ASSERT_NOT_NULL(strstr(response, "\\\"generation_matches\\\":true")); + ASSERT_NOT_NULL(strstr(response, "\\\"count_read_model\\\":\\\"canonical_only\\\"")); + free(response); - ASSERT_TRUE(succeeded); - ASSERT_TRUE(scanned_once); - ASSERT_TRUE(counted_once); - ASSERT_EQ(source_cross_edges, 1); - ASSERT_EQ(target_cross_edges, 1); + ASSERT_EQ(write_coverage_meta(store, "stale-generation", "complete"), CBM_STORE_OK); + response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":452,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\\\"coverage\\\":{\\\"status\\\":\\\"stale\\\"")); + ASSERT_NOT_NULL(strstr(response, "\\\"generation_matches\\\":false")); + free(response); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -/* `name` is the documented index project-name override and must identify the - * cross-repo source too. Deriving from repo_path here makes custom-named - * projects impossible to rescan even though ordinary indexing created them. */ -TEST(tool_cross_repo_honors_source_name_override) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-name-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); +TEST(tool_check_index_coverage_rejects_stale_generation) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(write_coverage_meta(store, "stale-generation", "complete"), CBM_STORE_OK); - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + char *response = cbm_mcp_handle_tool(srv, "check_index_coverage", + "{\"project\":\"test-project\",\"paths\":[\"main.go\"]}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":false")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"coverage_unavailable\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"read_source_and_reindex\"")); - const char *source_project = "cross-custom-source"; - const char *target_project = "cross-custom-target"; - ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); + free(inner); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); - char args[CBM_SZ_2K]; - snprintf(args, sizeof(args), - "{\"repo_path\":\"%s\",\"name\":\"%s\"," - "\"mode\":\"cross-repo-intelligence\"," - "\"target_projects\":[\"%s\"]}", - cache, source_project, target_project); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - bool succeeded = resp && !response_contains_json_fragment(resp, "\"isError\":true") && - response_contains_json_fragment(resp, "\"cross_http_calls\":1"); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_project_t project = {0}; + ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK); + ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK); + cbm_project_free_fields(&project); + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "fixture", 0, 0), + CBM_STORE_OK); - free(resp); - cbm_mcp_server_free(srv); - cleanup_project_db(cache, source_project); - cleanup_project_db(cache, target_project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); + /* A complete coverage recording is internally current for its published + * generation even when the live file has changed before watcher + * observation. The automatic context must scope that claim explicitly; + * the requested-path audit below then detects the live metadata change. */ + char *status_response = + cbm_mcp_handle_tool(srv, "trace_path", + "{\"project\":\"test-project\",\"function_name\":\"HandleRequest\"," + "\"format\":\"json\"}"); + ASSERT_NOT_NULL(status_response); + char *status_inner = extract_text_content(status_response); + ASSERT_NOT_NULL(status_inner); + ASSERT_NOT_NULL(strstr(status_inner, "\"coverage\":{\"status\":\"current\"")); + ASSERT_NOT_NULL(strstr(status_inner, "\"status_scope\":\"published_generation\"")); + ASSERT_NOT_NULL(strstr(status_inner, "\"live_source_freshness\":\"not_evaluated\"")); + free(status_inner); + free(status_response); - ASSERT_TRUE(succeeded); + char *response = cbm_mcp_handle_tool(srv, "check_index_coverage", + "{\"project\":\"test-project\",\"paths\":[\"main.go\"]}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"freshness\":\"metadata_changed\"")); + ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"read_source_and_reindex\"")); + + free(inner); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -/* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it - * is a mutation even when resolve_store() was reached by a query tool. Generic - * queries use a blocking guard for that recovery, while manage_adr reads must - * use one nonblocking acquisition and never nest a blocking lease. */ -TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-guard-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); +TEST(tool_check_index_coverage_surfaces_lookup_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_project_t project = {0}; + ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK); + ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK); + cbm_project_free_fields(&project); + ASSERT_EQ( + cbm_store_exec(store, "ALTER TABLE index_coverage RENAME COLUMN detail TO broken_detail;"), + CBM_STORE_OK); - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + char *response = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"test-project\",\"paths\":[\"main.go\"],\"scopes\":[\".\"]}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"coverage_lookup\":\"error\"")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"coverage_unavailable\"")); + ASSERT_NULL(strstr(inner, "\"status\":\"no_recorded_issue\"")); - const char *project = "guard-corrupt-project"; - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + free(inner); + free(response); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} - ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); - cbm_mcp_server_t *query_srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(query_srv); - mcp_mutation_guard_probe_t query_probe = { - .observed_db_path = db_path, - }; - cbm_mcp_server_set_project_mutation_guard(query_srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &query_probe); +/* Create a real committed repository without a shell or process-CWD + * dependency. Tests that exercise Git-backed MCP paths share this fixture so + * spaces and platform command interpreters cannot change their semantics. */ +static bool mcp_test_init_committed_repo(const char *repo, const char *relative_path) { + const char *const init_args[] = {"-c", "init.defaultBranch=main", "init", "-q", NULL}; + const char *const email_args[] = {"config", "user.email", "test@example.com", NULL}; + const char *const name_args[] = {"config", "user.name", "Test", NULL}; + const char *const signing_args[] = {"config", "commit.gpgsign", "false", NULL}; + const char *const add_args[] = {"add", relative_path, NULL}; + const char *const commit_args[] = {"commit", "-q", "-m", "initial", NULL}; + return repo && relative_path && cbm_git_drain_command(repo, init_args) == 0 && + cbm_git_drain_command(repo, email_args) == 0 && + cbm_git_drain_command(repo, name_args) == 0 && + cbm_git_drain_command(repo, signing_args) == 0 && + cbm_git_drain_command(repo, add_args) == 0 && + cbm_git_drain_command(repo, commit_args) == 0; +} - char *resp = - cbm_mcp_handle_tool(query_srv, "search_graph", - "{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}"); - free(resp); - cbm_mcp_server_free(query_srv); - char query_backup_path[CBM_SZ_1K]; - int query_backup_count = - mcp_find_corrupt_backups(cache, project, query_backup_path, sizeof(query_backup_path)); - bool query_quarantined = - !cbm_file_exists(db_path) && query_backup_count == 1 && query_backup_path[0] != '\0'; +TEST(tool_index_status_includes_git_metadata) { + /* The git context block moved behind verbose:true (lean-default contract, + * TOON round 2) — this test pins the verbose path's content; the default- + * omission guard lives in tool_lean_defaults_schema_and_status. */ + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); - /* Replant the same deterministic corruption to exercise manage_adr's - * already-held lease independently from the query server above. */ - mcp_cleanup_corrupt_backups(cache, project); - ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); - cbm_mcp_server_t *adr_srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(adr_srv); - mcp_mutation_guard_probe_t adr_probe = { - .observed_db_path = db_path, - }; - cbm_mcp_server_set_project_mutation_guard(adr_srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &adr_probe); - cbm_mcp_server_set_project_mutation_try_guard(adr_srv, mcp_mutation_guard_probe_try_begin); - resp = cbm_mcp_handle_tool(adr_srv, "manage_adr", - "{\"project\":\"guard-corrupt-project\",\"mode\":\"get\"}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"root_path\"")); + ASSERT_NOT_NULL(strstr(inner, "\"git\"")); + ASSERT_NOT_NULL(strstr(inner, "\"is_git\":false")); + ASSERT_NOT_NULL(strstr(inner, "\"root_exists\":true")); + + free(inner); free(resp); - cbm_mcp_server_free(adr_srv); - char adr_backup_path[CBM_SZ_1K]; - int adr_backup_count = - mcp_find_corrupt_backups(cache, project, adr_backup_path, sizeof(adr_backup_path)); - bool adr_quarantined = - !cbm_file_exists(db_path) && adr_backup_count == 1 && adr_backup_path[0] != '\0'; + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - - ASSERT_TRUE(query_quarantined); - ASSERT_EQ(query_probe.begin_count, 1); - ASSERT_EQ(query_probe.end_count, 1); - ASSERT_STR_EQ(query_probe.begin_projects[0], project); - ASSERT_STR_EQ(query_probe.end_projects[0], project); - ASSERT_TRUE(query_probe.db_exists_at_begin); - ASSERT_FALSE(query_probe.db_exists_at_end); - ASSERT_TRUE(adr_quarantined); - ASSERT_EQ(adr_probe.begin_count, 0); - ASSERT_EQ(adr_probe.try_begin_count, 1); - ASSERT_EQ(adr_probe.end_count, 1); - ASSERT_STR_EQ(adr_probe.try_begin_projects[0], project); - ASSERT_STR_EQ(adr_probe.end_projects[0], project); - ASSERT_TRUE(adr_probe.db_exists_at_begin); - ASSERT_FALSE(adr_probe.db_exists_at_end); - PASS(); -} +TEST(tool_index_status_distinguishes_dirty_worktree_from_head) { + char *tmp = th_mktempdir("cbm-status-git"); + ASSERT_NOT_NULL(tmp); + const char *const init_args[] = {"init", "-q", NULL}; + const char *const email_args[] = {"config", "user.email", "test@example.com", NULL}; + const char *const name_args[] = {"config", "user.name", "Test", NULL}; + if (cbm_git_drain_command(tmp, init_args) != 0 || + cbm_git_drain_command(tmp, email_args) != 0 || + cbm_git_drain_command(tmp, name_args) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git is unavailable"); + } + char source_path[CBM_SZ_1K]; + snprintf(source_path, sizeof(source_path), "%s/main.c", tmp); + ASSERT_EQ(th_write_file(source_path, "int main(void) { return 0; }\n"), 0); + const char *const add_args[] = {"add", "main.c", NULL}; + const char *const commit_args[] = {"commit", "-q", "-m", "initial", NULL}; + ASSERT_EQ(cbm_git_drain_command(tmp, add_args), 0); + ASSERT_EQ(cbm_git_drain_command(tmp, commit_args), 0); -/* Integrity is checked before the lease is requested, but quarantine itself - * must fail closed when that lease is denied. In particular, a rejected query - * may not remove either a recoverable DB generation or its committed WAL. */ -TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-denied-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "status-git-identity"; + ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "main", + .qualified_name = "status-git-identity.main", + .file_path = "main.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":161,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"status-git-identity\",\"verbose\":true}}}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"worktree_dirty\":false")); + ASSERT_NOT_NULL(strstr(inner, "\"head_matches_worktree\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"head_scope\":\"committed_revision_only\"")); + free(inner); + free(response); - const char *project = "guard-corrupt-denied"; - char db_path[CBM_SZ_1K]; - char wal_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); - cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); - ASSERT_NOT_NULL(writer); - ASSERT_TRUE(cbm_file_exists(db_path)); - ASSERT_TRUE(cbm_file_exists(wal_path)); + ASSERT_EQ(th_write_file(source_path, "int main(void) { return 1; }\n"), 0); + response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":162,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"status-git-identity\",\"verbose\":true}}}"); + ASSERT_NOT_NULL(response); + inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"worktree_dirty\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"head_matches_worktree\":false")); + ASSERT_NOT_NULL(strstr(inner, "\"head_scope\":\"committed_revision_only\"")); - long db_len = 0; - long wal_len = 0; - unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); - unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); - ASSERT_NOT_NULL(db_before); - ASSERT_NOT_NULL(wal_before); - ASSERT_TRUE(db_len > 0); - ASSERT_TRUE(wal_len > 0); + free(inner); + free(response); + cbm_mcp_server_free(srv); + th_rmtree(tmp); + PASS(); +} +TEST(tool_index_status_reports_dirty_metadata) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); - char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-denied\",\"name_pattern\":\".*\"}"); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "status-dirty"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/status-dirty"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); - bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); - char unexpected_backup[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); - int artifact_count = mcp_count_corrupt_artifacts(cache, project); - int begin_count = probe.begin_count; - int end_count = probe.end_count; - bool guarded_project = begin_count == 1 && strcmp(probe.begin_projects[0], project) == 0; + cbm_node_t node = {.project = proj, + .label = "Function", + .name = "StatusRun", + .qualified_name = "status-dirty.StatusRun", + .file_path = "status.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "status.c", + .observed_hash = "status-dirty-hash", + .observed_generation = 14, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":17,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"status-dirty\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"ready\"")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "index_status counts canonical graph rows")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + free(inner); free(resp); cbm_mcp_server_free(srv); - free(db_before); - free(wal_before); - cbm_store_close(writer); - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - - ASSERT_EQ(begin_count, 1); - ASSERT_EQ(end_count, 0); - ASSERT_TRUE(guarded_project); - ASSERT_TRUE(db_unchanged); - ASSERT_TRUE(wal_unchanged); - ASSERT_EQ(backup_count, 0); - ASSERT_EQ(artifact_count, 0); PASS(); } -/* A read must not wait for corrupt-store recovery. When another process owns - * that lease, distinguish the retryable busy state from an absent project. */ -TEST(tool_manage_adr_corrupt_store_busy_is_retryable) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-adr-corrupt-busy-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - const char *project = "guard-adr-corrupt-busy"; - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); - +TEST(tool_index_status_reports_overlay_read_view_counts) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {.deny_try_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); - cbm_mcp_server_set_project_mutation_try_guard(srv, mcp_mutation_guard_probe_try_begin); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "status-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/status-overlay"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); - char *resp = cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"guard-adr-corrupt-busy\",\"mode\":\"get\"}"); - bool retryable_busy = resp && strstr(resp, "project is busy; retry after indexing") && - response_contains_json_fragment(resp, "\"isError\":true"); - bool db_preserved = cbm_file_exists(db_path); - char unexpected_backup[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + cbm_node_t old_main = {.project = proj, + .label = "Function", + .name = "old_main", + .qualified_name = "status-overlay.old_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_node_t stable = {.project = proj, + .label = "Function", + .name = "stable", + .qualified_name = "status-overlay.stable", + .file_path = "stable.c", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_main), 0); + ASSERT_GT(cbm_store_upsert_node(st, &stable), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_main = {.project = proj, + .label = "Function", + .name = "new_main", + .qualified_name = "status-overlay.new_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "main.c", + .generation = 1, + .nodes = &new_main, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":18,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"status-overlay\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"nodes\":2")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_read_view\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_file_tombstones\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"canonical_nodes_visible\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_owned_nodes_visible\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"total_nodes_visible\":2")); + ASSERT_NOT_NULL(strstr(inner, "overlay-aware tools may read active overlay rows")); + free(inner); free(resp); cbm_mcp_server_free(srv); - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - - ASSERT_TRUE(retryable_busy); - ASSERT_EQ(probe.begin_count, 0); - ASSERT_EQ(probe.try_begin_count, 1); - ASSERT_EQ(probe.end_count, 0); - ASSERT_TRUE(db_preserved); - ASSERT_EQ(backup_count, 0); PASS(); } -TEST(tool_manage_adr_corrupt_store_missing_try_guard_reports_configuration) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-adr-corrupt-config-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); +/* ══════════════════════════════════════════════════════════════════ + * TOOL HANDLERS WITH DATA + * ══════════════════════════════════════════════════════════════════ */ - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); +TEST(tool_trace_path_not_found) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - const char *project = "guard-adr-corrupt-config"; - char db_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"function_name\":\"NonExistent\"," + "\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + /* Should return error about project not found */ + ASSERT_NOT_NULL(strstr(resp, "not found")); + free(resp); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_free(srv); + PASS(); +} - char *resp = cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"guard-adr-corrupt-config\",\"mode\":\"get\"}"); - bool missing_try_guard = - resp && strstr(resp, "project recovery requires a nonblocking mutation guard") && - response_contains_json_fragment(resp, "\"isError\":true"); - bool db_preserved = cbm_file_exists(db_path); - char unexpected_backup[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); +TEST(tool_trace_call_path_alias_dispatches) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\"," + "\"arguments\":{\"function_name\":\"NonExistent\"," + "\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not found")); + ASSERT_NULL(strstr(resp, "unknown tool")); free(resp); - cbm_mcp_server_free(srv); - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - ASSERT_TRUE(missing_try_guard); - ASSERT_EQ(probe.begin_count, 0); - ASSERT_EQ(probe.try_begin_count, 0); - ASSERT_EQ(probe.end_count, 0); - ASSERT_TRUE(db_preserved); - ASSERT_EQ(backup_count, 0); + cbm_mcp_server_free(srv); PASS(); } -/* Another session may publish a good generation while this query waits for - * the mutation lease. Cleanup must re-open and re-check the path after lease - * acquisition; quarantining based on the stale pre-wait handle loses the new - * generation and returns a false "not indexed" result. */ -TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-recheck-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); +TEST(tool_trace_missing_function_name) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "required")); + free(resp); - const char *project = "guard-corrupt-recheck"; - const char *replacement_root = "/tmp/guard-corrupt-replacement"; - char db_path[CBM_SZ_1K]; - char replacement_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - snprintf(replacement_path, sizeof(replacement_path), "%s/%s.replacement.db", cache, project); - ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); - ASSERT_TRUE(mcp_make_valid_project_store_at(replacement_path, project, replacement_root)); + cbm_mcp_server_free(srv); + PASS(); +} +/* Regression: two same-named definitions with equal rank must be reported + * ambiguous, not silently traced (trace_path previously took nodes[0]). */ +TEST(tool_trace_path_ambiguous) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - mcp_replacing_mutation_guard_t replacement = { - .replacement_path = replacement_path, - .live_path = db_path, - }; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_replacing_mutation_guard_begin, - mcp_replacing_mutation_guard_end, &replacement); - char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}"); - bool response_used_replacement = - resp && !response_contains_json_fragment(resp, "\"isError\":true"); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "amb-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/amb"); + cbm_node_t a = {.project = proj, + .label = "Function", + .name = "amb", + .qualified_name = "amb-proj.a.amb", + .file_path = "a.c", + .start_line = 10, + .end_line = 20}; + cbm_node_t b = {.project = proj, + .label = "Function", + .name = "amb", + .qualified_name = "amb-proj.b.amb", + .file_path = "b.c", + .start_line = 10, + .end_line = 20}; /* equal span -> genuine tie */ + ASSERT_GT(cbm_store_upsert_node(st, &a), 0); + ASSERT_GT(cbm_store_upsert_node(st, &b), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":61,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"function_name\":\"amb\",\"project\":\"amb-proj\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "ambiguous")); + ASSERT_NOT_NULL(strstr(inner, "suggestions")); + ASSERT_NULL(strstr(inner, "\"callees\"")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Multi-seed union hop semantics: bfs_union_same_name deduped visited nodes + * keep-FIRST-seen, so a node reached at hop 2 from the first seed kept hop 2 + * even when the second seed reaches it at hop 1. hop feeds risk_labels and + * (soon) pagination watermarks — it must be the MINIMUM across seeds, matching + * the single-BFS MIN(hop) semantics (#797). */ +TEST(tool_trace_union_records_min_hop_across_seeds) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "dualproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/dual"); + + /* One real definition + one body-less stub (start==end) — the #546/#650 + * shape pick_resolved_node resolves WITHOUT ambiguity while + * bfs_union_same_name still traverses both. Seed A (real def, lower id, + * traversed first) reaches tgt only via mid (hop 2); the stub seed B + * reaches tgt directly (hop 1). */ + cbm_node_t sa = {.project = proj, + .label = "Function", + .name = "dual", + .qualified_name = "dualproj.a.dual", + .file_path = "a.c", + .start_line = 1, + .end_line = 50}; + cbm_node_t sb = {.project = proj, + .label = "Function", + .name = "dual", + .qualified_name = "dualproj.b.dual", + .file_path = "b.d.ts", + .start_line = 1, + .end_line = 1}; + cbm_node_t mid = {.project = proj, + .label = "Function", + .name = "mid", + .qualified_name = "dualproj.c.mid", + .file_path = "c.c", + .start_line = 1, + .end_line = 5}; + cbm_node_t tgt = {.project = proj, + .label = "Function", + .name = "tgt", + .qualified_name = "dualproj.c.tgt", + .file_path = "c.c", + .start_line = 10, + .end_line = 15}; + int64_t ida = cbm_store_upsert_node(st, &sa); + int64_t idb = cbm_store_upsert_node(st, &sb); + int64_t idm = cbm_store_upsert_node(st, &mid); + int64_t idt = cbm_store_upsert_node(st, &tgt); + ASSERT_GT(ida, 0); + ASSERT_GT(idb, 0); + ASSERT_GT(idm, 0); + ASSERT_GT(idt, 0); + cbm_edge_t e1 = {.project = proj, .source_id = ida, .target_id = idm, .type = "CALLS"}; + cbm_edge_t e2 = {.project = proj, .source_id = idm, .target_id = idt, .type = "CALLS"}; + cbm_edge_t e3 = {.project = proj, .source_id = idb, .target_id = idt, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e1), 0); + ASSERT_GT(cbm_store_insert_edge(st, &e2), 0); + ASSERT_GT(cbm_store_insert_edge(st, &e3), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\"," + "\"arguments\":{\"function_name\":\"dual\",\"project\":\"dualproj\"," + "\"direction\":\"outbound\",\"depth\":3}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + /* tgt is one hop from seed B — the union must record hop 1, not seed A's 2. */ + ASSERT_NOT_NULL(strstr(inner, "\"qualified_name\":\"dualproj.c.tgt\",\"hop\":1")); + ASSERT_NULL(strstr(inner, "\"qualified_name\":\"dualproj.c.tgt\",\"hop\":2")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Exactly-once trace pagination: 12 callees paged at limit=5 must yield + * 5+5+2 rows with every callee appearing on exactly one page, exact totals + * on every page, and a final page without a cursor. Stale and mismatched + * cursors must fail with teaching errors, never silently restart. */ +TEST(tool_trace_pagination_exactly_once) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "pageproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/page"); + + cbm_node_t hub = {.project = proj, + .label = "Function", + .name = "hub", + .qualified_name = "pageproj.h.hub", + .file_path = "h.c", + .start_line = 1, + .end_line = 9}; + int64_t hid = cbm_store_upsert_node(st, &hub); + ASSERT_GT(hid, 0); + enum { CALLEES = 12 }; + for (int i = 0; i < CALLEES; i++) { + char nm[16]; + char qn[48]; + snprintf(nm, sizeof(nm), "c%02d", i); + snprintf(qn, sizeof(qn), "pageproj.m.c%02d", i); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = nm, + .qualified_name = qn, + .file_path = "m.c", + .start_line = 1, + .end_line = 3}; + int64_t nid = cbm_store_upsert_node(st, &n); + ASSERT_GT(nid, 0); + cbm_edge_t e = {.project = proj, .source_id = hid, .target_id = nid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e), 0); + } + + char pages[3][4096]; + char tok[192] = ""; + int npages = 0; + for (; npages < 3; npages++) { + char req[640]; + if (tok[0]) { + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5," + "\"cursor\":\"%s\"}}}", + tok); + } else { + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5}}}"); + } + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + snprintf(pages[npages], sizeof(pages[npages]), "%s", inner); + ASSERT_NOT_NULL(strstr(inner, "callees_total: 12")); /* exact total, every page */ + const char *nx = strstr(inner, "next: "); + if (nx) { + const char *e = strchr(nx + 6, '\n'); + size_t tl = e ? (size_t)(e - (nx + 6)) : strlen(nx + 6); + ASSERT_TRUE(tl < sizeof(tok)); + memcpy(tok, nx + 6, tl); + tok[tl] = '\0'; + } else { + tok[0] = '\0'; + } + free(inner); + if (!tok[0]) { + npages++; + break; + } + } + ASSERT_EQ(npages, 3); /* 5 + 5 + 2 */ + /* Exactly-once: every callee appears on exactly ONE page. */ + for (int i = 0; i < CALLEES; i++) { + char qn[48]; + snprintf(qn, sizeof(qn), "pageproj.m.c%02d,1\n", i); + int seen = 0; + for (int p = 0; p < 3; p++) { + if (strstr(pages[p], qn)) { + seen++; + } + } + ASSERT_EQ(seen, 1); + } + /* Final page carries no cursor. */ + ASSERT_NULL(strstr(pages[2], "next: ")); + + /* Params mismatch: replay a page-2-era cursor with a different depth. */ + const char *nx1 = strstr(pages[0], "next: "); + ASSERT_NOT_NULL(nx1); + char tok1[192]; + const char *e1 = strchr(nx1 + 6, '\n'); + size_t tl1 = e1 ? (size_t)(e1 - (nx1 + 6)) : strlen(nx1 + 6); + memcpy(tok1, nx1 + 6, tl1); + tok1[tl1] = '\0'; + char req2[640]; + snprintf(req2, sizeof(req2), + "{\"jsonrpc\":\"2.0\",\"id\":81,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,\"depth\":2," + "\"cursor\":\"%s\"}}}", + tok1); + char *resp = cbm_mcp_server_handle(srv, req2); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "cursor_params_mismatch")); + free(inner); + + /* Stale: an index run (upsert_project bumps the generation) invalidates + * outstanding cursors with a loud, actionable error. */ + cbm_store_upsert_project(st, proj, "/tmp/page"); + snprintf(req2, sizeof(req2), + "{\"jsonrpc\":\"2.0\",\"id\":82,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5," + "\"cursor\":\"%s\"}}}", + tok1); + resp = cbm_mcp_server_handle(srv, req2); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "stale_cursor")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* Regression: when same-named nodes differ in rank, trace must pick the real + * definition (callable, larger body) — NOT nodes[0]. The Module is inserted + * first; if trace took nodes[0] the outbound trace would be empty. */ +TEST(tool_trace_path_prefers_definition) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "pref-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/pref"); + /* nodes[0]: the WRONG match (a Module, tiny span), inserted first. */ + cbm_node_t wrong = {.project = proj, + .label = "Module", + .name = "dup", + .qualified_name = "pref-proj.dup", + .file_path = "dup.x", + .start_line = 1, + .end_line = 1}; + /* the real definition: a Function with a body. */ + cbm_node_t def = {.project = proj, + .label = "Function", + .name = "dup", + .qualified_name = "pref-proj.src.dup", + .file_path = "src/dup.c", + .start_line = 10, + .end_line = 50}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "callee", + .qualified_name = "pref-proj.src.callee", + .file_path = "src/dup.c", + .start_line = 60, + .end_line = 70}; + ASSERT_GT(cbm_store_upsert_node(st, &wrong), 0); + int64_t id_def = cbm_store_upsert_node(st, &def); + int64_t id_callee = cbm_store_upsert_node(st, &callee); + ASSERT_GT(id_def, 0); + ASSERT_GT(id_callee, 0); + cbm_edge_t e = {.project = proj, .source_id = id_def, .target_id = id_callee, .type = "CALLS"}; + cbm_store_insert_edge(st, &e); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"dup\"," + "\"project\":\"pref-proj\",\"direction\":\"outbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "ambiguous")); + /* picked the Function definition -> its outbound CALLS edge to "callee" shows */ + ASSERT_NOT_NULL(strstr(inner, "callee")); + free(inner); + free(resp); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"dup\"," + "\"project\":\"pref-proj\",\"direction\":\"outbound\",\"max_results\":0}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callee")); + free(inner); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_warns_on_stale_rank_views) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "trace-stale"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/trace-stale"), CBM_STORE_OK); + + cbm_node_t root = {.project = proj, + .label = "Function", + .name = "root", + .qualified_name = "trace-stale.root", + .file_path = "root.c", + .start_line = 1, + .end_line = 10}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "callee", + .qualified_name = "trace-stale.callee", + .file_path = "callee.c", + .start_line = 11, + .end_line = 20}; + int64_t root_id = cbm_store_upsert_node(st, &root); + int64_t callee_id = cbm_store_upsert_node(st, &callee); + ASSERT_GT(root_id, 0); + ASSERT_GT(callee_id, 0); + cbm_edge_t edge = {.project = proj, + .source_id = root_id, + .target_id = callee_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + + const char *stale_views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_VIEW_LINKRANK}; + ASSERT_EQ(cbm_store_mark_derived_views_stale(st, proj, CBM_STORE_DERIVED_GENERATION_UNKNOWN, + stale_views, + (int)(sizeof(stale_views) / sizeof(stale_views[0]))), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":64,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"function_name\":\"root\",\"project\":\"trace-stale\"," + "\"direction\":\"outbound\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callee")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "pagerank derived view is stale")); + ASSERT_NOT_NULL(strstr(inner, "linkrank derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_PAGERANK)); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_LINKRANK)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_trace_path_reports_dirty_metadata_as_canonical_only) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "trace-dirty"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/trace-dirty"), CBM_STORE_OK); + + cbm_node_t root = {.project = proj, + .label = "Function", + .name = "root", + .qualified_name = "trace-dirty.root", + .file_path = "root.c"}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "callee", + .qualified_name = "trace-dirty.callee", + .file_path = "callee.c"}; + int64_t root_id = cbm_store_upsert_node(st, &root); + int64_t callee_id = cbm_store_upsert_node(st, &callee); + ASSERT_GT(root_id, 0); + ASSERT_GT(callee_id, 0); + cbm_edge_t edge = {.project = proj, + .source_id = root_id, + .target_id = callee_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &edge), 0); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "root.c", + .observed_hash = "trace-dirty-hash", + .observed_generation = 12, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":65,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\"," + "\"arguments\":{\"function_name\":\"root\",\"project\":\"trace-dirty\"," + "\"direction\":\"outbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callee")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "trace_path reads canonical graph rows")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* CONTRACT PIN for the closed strategy vocabulary published by + * trace_path(include_evidence:true). + * + * The indexer records ~20 internal strategy names on CALLS edges and the set + * grows with every language added. We publish a CLASS, not the raw name, so a + * resolver rename cannot silently change a user-visible field. This test is + * what keeps that promise honest: every strategy production can emit must land + * in a known class. Adding lsp_foo_dispatch passes automatically; introducing a + * genuinely new KIND of resolution fails HERE and forces a deliberate decision + * about the public contract instead of leaking an internal name. */ +TEST(trace_evidence_strategy_class_vocabulary_is_closed) { + /* Every strategy string assigned anywhere in src/ + internal/ as of this + * commit, plus the two literals pass_calls.c writes directly. */ + static const char *const lsp[] = {"lsp_direct", "lsp_base_dispatch", + "lsp_embed_dispatch", "lsp_implicit_this", + "lsp_inherited_dispatch", "lsp_method_dispatch", + "lsp_proc_macro", "lsp_smart_ptr_dispatch", + "lsp_strategy_cross_file", "lsp_trait_dispatch", + "lsp_type_dispatch", "lsp_virtual_dispatch"}; + for (size_t i = 0; i < sizeof(lsp) / sizeof(lsp[0]); i++) { + const char *cls = cbm_mcp_edge_strategy_class(lsp[i]); + ASSERT_NOT_NULL(cls); + ASSERT_STR_EQ(cls, "lsp"); + } + static const char *const lang[] = {"php_self_static", "php_static_resolved", + "perl_method_static", "perl_method_typed"}; + for (size_t i = 0; i < sizeof(lang) / sizeof(lang[0]); i++) { + const char *cls = cbm_mcp_edge_strategy_class(lang[i]); + ASSERT_NOT_NULL(cls); + ASSERT_STR_EQ(cls, "language_rule"); + } + static const char *const heur[] = {"callee_suffix", "field_type_hint", "service_pattern", + "fastapi_depends"}; + for (size_t i = 0; i < sizeof(heur) / sizeof(heur[0]); i++) { + const char *cls = cbm_mcp_edge_strategy_class(heur[i]); + ASSERT_NOT_NULL(cls); + ASSERT_STR_EQ(cls, "heuristic"); + } + /* A failed LSP resolution is reported as unresolved, not as "lsp" — the + * caller's question is whether the edge is trustworthy, and "we tried LSP + * and it did not resolve" answers no. */ + ASSERT_STR_EQ(cbm_mcp_edge_strategy_class("lsp_unresolved"), "unresolved"); + ASSERT_STR_EQ(cbm_mcp_edge_strategy_class("unknown"), "unresolved"); + /* Only a NULL/empty strategy is unclassified — an unmapped non-empty value + * must never silently disappear from the output. */ + ASSERT_NULL(cbm_mcp_edge_strategy_class(NULL)); + ASSERT_NULL(cbm_mcp_edge_strategy_class("")); + ASSERT_STR_EQ(cbm_mcp_edge_strategy_class("some_future_resolver"), "heuristic"); + PASS(); +} + +/* Distilled from #559 (@vvenegasv). The indexer already records + * {strategy, confidence} on every CALLS edge (pass_calls.c:355) and the store + * reads it back, but no tool ever surfaced it — an agent could see THAT A->B + * exists, never HOW it was resolved. + * + * Binds two things at once: the evidence columns appear only when asked for + * (default stays lean), and the published value is the CLASS, not the raw + * internal strategy name. Fails without the production change in both + * directions — no columns at all before, and "lsp_trait_dispatch" would leak + * verbatim if the classifier were bypassed. */ +TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "ev-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/ev"); + cbm_node_t caller = {.project = proj, + .label = "Function", + .name = "caller", + .qualified_name = "ev-proj.src.caller", + .file_path = "src/a.c", + .start_line = 1, + .end_line = 5}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "target", + .qualified_name = "ev-proj.src.target", + .file_path = "src/a.c", + .start_line = 10, + .end_line = 20}; + int64_t id_caller = cbm_store_upsert_node(st, &caller); + int64_t id_callee = cbm_store_upsert_node(st, &callee); + ASSERT_GT(id_caller, 0); + ASSERT_GT(id_callee, 0); + /* Exactly the shape pass_calls.c:355 writes in production. */ + cbm_edge_t e = {.project = proj, + .source_id = id_caller, + .target_id = id_callee, + .type = "CALLS", + .properties_json = "{\"callee\":\"target\",\"confidence\":0.95," + "\"strategy\":\"lsp_trait_dispatch\",\"candidates\":1}"}; + ASSERT_GT(cbm_store_insert_edge(st, &e), 0); + + /* Default: lean. No evidence columns, no strategy anywhere. */ + char *plain = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"caller\"," + "\"project\":\"ev-proj\",\"direction\":\"outbound\"}}}"); + ASSERT_NOT_NULL(plain); + char *plain_txt = extract_text_content(plain); + ASSERT_NOT_NULL(plain_txt); + ASSERT_NOT_NULL(strstr(plain_txt, "target")); /* positive control: the hop IS there */ + ASSERT_NULL(strstr(plain_txt, "lsp")); + ASSERT_NULL(strstr(plain_txt, "0.95")); + free(plain_txt); + free(plain); + + /* Opted in: the class and the confidence appear, the raw name does not. */ + char *ev = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{\"function_name\":\"caller\"," + "\"project\":\"ev-proj\",\"direction\":\"outbound\",\"include_evidence\":true}}}"); + ASSERT_NOT_NULL(ev); + char *ev_txt = extract_text_content(ev); + ASSERT_NOT_NULL(ev_txt); + ASSERT_NOT_NULL(strstr(ev_txt, "target")); + ASSERT_NOT_NULL(strstr(ev_txt, "lsp")); + ASSERT_NOT_NULL(strstr(ev_txt, "0.95")); + /* The internal resolver name must NOT reach the client. */ + ASSERT_NULL(strstr(ev_txt, "lsp_trait_dispatch")); + free(ev_txt); + free(ev); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Evidence belongs to the shortest-path predecessor edge, not merely the + * first induced edge incident to a result node. Both target and via are one + * hop from root, while via->target is lateral. Ordering via before root makes + * the old incident-edge scan deterministically misattribute target as a + * heuristic even though root->target is the edge that reaches it at hop 1. */ +TEST(tool_trace_path_evidence_uses_shortest_path_predecessor) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "ev-shortest"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/ev-shortest"); + ASSERT_EQ(cbm_store_set_derived_view_state(st, proj, CBM_STORE_DERIVED_VIEW_LINKRANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + + cbm_node_t root = {.project = proj, + .label = "Function", + .name = "zroot", + .qualified_name = "ev-shortest.src.zroot"}; + cbm_node_t via = {.project = proj, + .label = "Function", + .name = "avia", + .qualified_name = "ev-shortest.src.avia"}; + cbm_node_t target = {.project = proj, + .label = "Function", + .name = "target", + .qualified_name = "ev-shortest.src.target"}; + int64_t root_id = cbm_store_upsert_node(st, &root); + int64_t via_id = cbm_store_upsert_node(st, &via); + int64_t target_id = cbm_store_upsert_node(st, &target); + ASSERT_GT(root_id, 0); + ASSERT_GT(via_id, 0); + ASSERT_GT(target_id, 0); + + cbm_edge_t root_via = {.project = proj, + .source_id = root_id, + .target_id = via_id, + .type = "CALLS", + .properties_json = "{\"strategy\":\"lsp_direct\",\"confidence\":0.8}"}; + cbm_edge_t root_target = { + .project = proj, + .source_id = root_id, + .target_id = target_id, + .type = "CALLS", + .properties_json = + "{\"strategy\":\"lsp_direct\",\"confidence\":0.9,\"args\":[\"correct\"]}"}; + cbm_edge_t lateral = { + .project = proj, + .source_id = via_id, + .target_id = target_id, + .type = "CALLS", + .properties_json = + "{\"strategy\":\"callee_suffix\",\"confidence\":0.1,\"args\":[\"wrong\"]}"}; + ASSERT_GT(cbm_store_insert_edge(st, &root_via), 0); + ASSERT_GT(cbm_store_insert_edge(st, &lateral), 0); + ASSERT_GT(cbm_store_insert_edge(st, &root_target), 0); + + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_path\",\"arguments\":{" + "\"function_name\":\"zroot\",\"project\":\"ev-shortest\"," + "\"direction\":\"outbound\",\"mode\":\"data_flow\",\"format\":\"json\"," + "\"include_evidence\":true}}}"); + ASSERT_NOT_NULL(response); + char *text = extract_text_content(response); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "\"qualified_name\":\"ev-shortest.src.target\",\"hop\":1," + "\"args\":[\"correct\"],\"strategy\":\"lsp\"")); + ASSERT_NULL(strstr(text, "\"qualified_name\":\"ev-shortest.src.target\",\"hop\":1," + "\"args\":[\"correct\"],\"strategy\":\"heuristic\"")); + ASSERT_NULL(strstr(text, "\"args\":[\"wrong\"]")); + + free(text); + free(response); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Reproduce-first (#887): the client-supplied `depth` on trace_call_path must be + * clamped to the MCP ceiling (cbm_mcp_max_depth(), default 15). On origin/main + * an MCP_MAX_DEPTH=15 constant was defined but never applied — `depth` flowed + * straight into bfs_union_same_name, so an unbounded value drives the shared + * cbm_store_bfs to arbitrary depth. Over an 18-node call chain, depth=1000 + * reaches n16/n17 (RED); with the clamp the walk stops at hop 15, so n15 is + * reached but n16 is not (GREEN). Quoted tokens ("n15"/"n16") match only the + * node-name field, never the qualified_name (preceded by '.'), so the boundary + * check is exact. */ +TEST(tool_trace_call_path_depth_clamped) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "depth-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/depth"); + + /* Linear call chain n00 -CALLS-> n01 -> ... -> n17 (18 nodes). */ + int64_t ids[18]; + for (int i = 0; i < 18; i++) { + char name[8]; + char qn[32]; + snprintf(name, sizeof(name), "n%02d", i); + snprintf(qn, sizeof(qn), "depth-proj.n%02d", i); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "chain.c", + .start_line = 1, + .end_line = 2}; + ids[i] = cbm_store_upsert_node(st, &n); + } + for (int i = 0; i < 17; i++) { + cbm_edge_t e = { + .project = proj, .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + cbm_store_insert_edge(st, &e); + } + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"n00\"," + "\"project\":\"depth-proj\",\"direction\":\"outbound\",\"depth\":1000}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + /* Reached within the ceiling (proves the traversal ran) but clamped at 15. + * TOON rows carry bare QNs, so match the names unquoted. */ + ASSERT_NOT_NULL(strstr(inner, "n15")); + ASSERT_NULL(strstr(inner, "n16")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Reproduce-first (#650, distilled): two GENUINELY-DIFFERENT same-named functions + * whose bodies differ in length score differently, so the old exact-tie check did + * not flag them ambiguous — and bfs_union_same_name (#546) then merged the caller + * sets of both into one confidently-conflated answer (the mirror of #546's under- + * report). The fix: 2+ real callable defs => ambiguous (disambiguate), never union + * distinct symbols. RED before the pick_resolved_node real_def_count rule (response + * merged callerA+callerB), GREEN after (response is ambiguous, no "callers"). */ +TEST(tool_trace_call_path_distinct_defs_not_over_unioned) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "ou-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/ou"); + /* two unrelated real definitions of "dupreal", DIFFERENT body spans */ + cbm_node_t da = {.project = proj, + .label = "Function", + .name = "dupreal", + .qualified_name = "ou-proj.a.dupreal", + .file_path = "a.c", + .start_line = 10, + .end_line = 20}; /* span 10 */ + cbm_node_t db = {.project = proj, + .label = "Function", + .name = "dupreal", + .qualified_name = "ou-proj.b.dupreal", + .file_path = "b.c", + .start_line = 10, + .end_line = 40}; /* span 30 (no tie) */ + cbm_node_t ca = {.project = proj, + .label = "Function", + .name = "callerA", + .qualified_name = "ou-proj.a.callerA", + .file_path = "a.c", + .start_line = 30, + .end_line = 40}; + cbm_node_t cb = {.project = proj, + .label = "Function", + .name = "callerB", + .qualified_name = "ou-proj.b.callerB", + .file_path = "b.c", + .start_line = 50, + .end_line = 60}; + int64_t id_da = cbm_store_upsert_node(st, &da); + int64_t id_db = cbm_store_upsert_node(st, &db); + int64_t id_ca = cbm_store_upsert_node(st, &ca); + int64_t id_cb = cbm_store_upsert_node(st, &cb); + ASSERT_GT(id_da, 0); + ASSERT_GT(id_db, 0); + ASSERT_GT(id_ca, 0); + ASSERT_GT(id_cb, 0); + cbm_edge_t ea = {.project = proj, .source_id = id_ca, .target_id = id_da, .type = "CALLS"}; + cbm_edge_t eb = {.project = proj, .source_id = id_cb, .target_id = id_db, .type = "CALLS"}; + cbm_store_insert_edge(st, &ea); + cbm_store_insert_edge(st, &eb); + + char *resp = cbm_mcp_server_handle( + srv, + "{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dupreal\"," + "\"project\":\"ou-proj\",\"direction\":\"inbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + /* distinct symbols must be disambiguated, not merged into one caller set */ + ASSERT_NOT_NULL(strstr(inner, "ambiguous")); + ASSERT_NOT_NULL(strstr(inner, "suggestions")); + ASSERT_NULL(strstr(inner, "\"callers\"")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Guard that the ambiguity gate does NOT regress the #546 fix: a real .ts + * implementation plus a body-less ambient .d.ts stub is ONE logical symbol + * (one real callable def + a fragment), so it must stay non-ambiguous and the + * caller sets from both nodes must be unioned. */ +TEST(tool_trace_call_path_dts_stub_unions_with_impl) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "dts-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/dts"); + cbm_node_t impl = {.project = proj, + .label = "Function", + .name = "sym546", + .qualified_name = "dts-proj.impl.sym546", + .file_path = "src/sym.ts", + .start_line = 10, + .end_line = 30}; /* real body */ + cbm_node_t stub = {.project = proj, + .label = "Function", + .name = "sym546", + .qualified_name = "dts-proj.stub.sym546", + .file_path = "types/sym.d.ts", + .start_line = 5, + .end_line = 5}; /* body-less ambient decl */ + cbm_node_t crel = {.project = proj, + .label = "Function", + .name = "callerRel", + .qualified_name = "dts-proj.callerRel", + .file_path = "src/rel.ts", + .start_line = 1, + .end_line = 8}; + cbm_node_t cali = {.project = proj, + .label = "Function", + .name = "callerAlias", + .qualified_name = "dts-proj.callerAlias", + .file_path = "src/ali.ts", + .start_line = 1, + .end_line = 8}; + int64_t id_impl = cbm_store_upsert_node(st, &impl); + int64_t id_stub = cbm_store_upsert_node(st, &stub); + int64_t id_crel = cbm_store_upsert_node(st, &crel); + int64_t id_cali = cbm_store_upsert_node(st, &cali); + ASSERT_GT(id_impl, 0); + ASSERT_GT(id_stub, 0); + ASSERT_GT(id_crel, 0); + ASSERT_GT(id_cali, 0); + /* callers split by import style: relative -> impl, path-alias -> stub */ + cbm_edge_t er = {.project = proj, .source_id = id_crel, .target_id = id_impl, .type = "CALLS"}; + cbm_edge_t el = {.project = proj, .source_id = id_cali, .target_id = id_stub, .type = "CALLS"}; + cbm_store_insert_edge(st, &er); + cbm_store_insert_edge(st, &el); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":64,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"sym546\"," + "\"project\":\"dts-proj\",\"direction\":\"inbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "ambiguous")); + /* union across impl + stub: BOTH callers appear (this is the #546 fix) */ + ASSERT_NOT_NULL(strstr(inner, "callerRel")); + ASSERT_NOT_NULL(strstr(inner, "callerAlias")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_delete_project_not_found) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":22,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"delete_project\"," + "\"arguments\":{\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not_found")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_architecture_empty) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":24,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + /* No store for nonexistent project — should return project error */ + ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* Regression for #281: handle_get_architecture must actually call + * cbm_store_get_architecture and surface its sections. Before the fix + * only label/edge histograms were emitted regardless of which aspects + * were requested. The store-side arch_entry_points query reads + * properties.is_entry_point on Function nodes, so we tag one node and + * assert the resulting JSON surfaces an "entry_points" array containing + * the tagged function — which is impossible without the wiring. */ +TEST(tool_get_architecture_emits_populated_sections) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-test"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/arch-test"); + + cbm_node_t main_fn = {0}; + main_fn.project = proj; + main_fn.label = "Function"; + main_fn.name = "main"; + main_fn.qualified_name = "arch-test.cmd.main"; + main_fn.file_path = "cmd/main.go"; + main_fn.start_line = 1; + main_fn.end_line = 3; + main_fn.properties_json = "{\"is_entry_point\":true}"; + ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-test\",\"aspects\":[\"all\"]}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + /* The handler always emits node/edge counts and schema histograms; + * those existed before #281. The "entry_points" array only appears + * when cbm_store_get_architecture is actually called and its result + * is serialized — which is exactly what #281 wires up. */ + ASSERT_NOT_NULL(strstr(inner, "entry_points[")); + ASSERT_NOT_NULL(strstr(inner, "main")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_architecture_reports_cluster_budget_omission) { + char config_dir[CBM_PATH_MAX]; + ASSERT_TRUE(snprintf(config_dir, sizeof(config_dir), "/tmp/cbm-mcp-cluster-budget-XXXXXX") > 0); + ASSERT_NOT_NULL(cbm_mkdtemp(config_dir)); + cbm_config_t *config = cbm_config_open(config_dir); + ASSERT_NOT_NULL(config); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_ARCH_CLUSTER_NODE_BUDGET, "4"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, config); + cbm_mcp_server_set_project(srv, "cluster-budget-mcp"); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "cluster-budget-mcp", "/tmp/cluster-budget-mcp"), + CBM_STORE_OK); + for (int i = 0; i < 5; i++) { + char name[CBM_SZ_32]; + char qn[CBM_SZ_128]; + ASSERT_TRUE(snprintf(name, sizeof(name), "function%d", i) > 0); + ASSERT_TRUE(snprintf(qn, sizeof(qn), "cluster-budget-mcp.pkg.%s", name) > 0); + cbm_node_t node = {.project = "cluster-budget-mcp", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "cluster.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + } + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"cluster-budget-mcp\"," + "\"aspects\":[\"clusters\"],\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"clusters_omitted_for_budget\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"cluster_nodes_total\":5")); + ASSERT_NOT_NULL(strstr(inner, "\"cluster_node_budget\":4")); + ASSERT_NOT_NULL(strstr(inner, "raise arch_cluster_node_budget")); + ASSERT_NULL(strstr(inner, "\"clusters\":[")); + + free(inner); + free(resp); + + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"cluster-budget-mcp\"," + "\"aspects\":[\"clusters\"],\"format\":\"toon\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "clusters_omitted_for_budget: true")); + ASSERT_NOT_NULL(strstr(inner, "cluster_nodes_total: 5")); + ASSERT_NOT_NULL(strstr(inner, "cluster_node_budget: 4")); + ASSERT_NOT_NULL(strstr(inner, "raise arch_cluster_node_budget")); + ASSERT_NULL(strstr(inner, "clusters[")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cbm_config_close(config); + char config_path[CBM_PATH_MAX]; + ASSERT_TRUE(snprintf(config_path, sizeof(config_path), "%s/_config.db", config_dir) > 0); + cbm_remove_db_sidecars(config_path); + cbm_unlink(config_path); + cbm_rmdir(config_dir); + PASS(); +} + +TEST(tool_get_architecture_warns_on_stale_derived_views) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/arch-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t fn = {.project = proj, + .label = "Function", + .name = "Run", + .qualified_name = "arch-stale.Run", + .file_path = "run.c"}; + int64_t id = cbm_store_upsert_node(st, &fn); + ASSERT_GT(id, 0); + char rank_sql[256]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('arch-stale',%lld,0.9,'2026-06-30T00:00:00Z')", + (long long)id); + ASSERT_EQ(cbm_store_exec(st, rank_sql), CBM_STORE_OK); + const char *stale_views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_VIEW_ARCHITECTURE}; + ASSERT_EQ(cbm_store_mark_derived_views_stale(st, proj, CBM_STORE_DERIVED_GENERATION_UNKNOWN, + stale_views, + (int)(sizeof(stale_views) / sizeof(stale_views[0]))), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-stale\",\"aspects\":[\"all\"]," + "\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "architecture derived view is stale")); + ASSERT_NOT_NULL(strstr(inner, "routes derived view is stale")); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_ARCHITECTURE)); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_ROUTES)); + ASSERT(has_stale_freshness_view(inner, CBM_STORE_DERIVED_VIEW_PAGERANK)); + ASSERT_NOT_NULL(strstr(inner, "key_functions were omitted")); + ASSERT_NOT_NULL(strstr(inner, "\"action_required\"")); + ASSERT_NOT_NULL(strstr(inner, "index_repository")); + ASSERT_NULL(strstr(inner, "\"key_functions\"")); + free(inner); + free(resp); + + resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-stale\",\"aspects\":[\"all\"]," + "\"format\":\"toon\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "freshness_state: stale_with_warning")); + ASSERT_NOT_NULL(strstr(inner, "freshness_stale_views:")); + ASSERT_NOT_NULL(strstr(inner, CBM_STORE_DERIVED_VIEW_ARCHITECTURE)); + ASSERT_NOT_NULL(strstr(inner, CBM_STORE_DERIVED_VIEW_ROUTES)); + ASSERT_NOT_NULL(strstr(inner, CBM_STORE_DERIVED_VIEW_PAGERANK)); + ASSERT_NOT_NULL(strstr(inner, "architecture derived view is stale")); + ASSERT_NOT_NULL(strstr(inner, "routes derived view is stale")); + ASSERT_NOT_NULL(strstr(inner, "key_functions were omitted")); + ASSERT_NOT_NULL(strstr(inner, "action_required:")); + ASSERT_NOT_NULL(strstr(inner, "index_repository")); + ASSERT_NULL(strstr(inner, "key_functions[")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Distills PR #560 (overview subset): "overview" must expand to a compact + * subset — every aspect EXCEPT file_tree. Before the fix, "overview" was not + * registered in either aspect gate (want_aspect in store.c, aspect_wanted in + * mcp.c), so aspects=["overview"] silently degraded to just + * {total_nodes,total_edges}. RED on unfixed code: no "entry_points" key. */ +TEST(tool_get_architecture_overview_compact_subset_pr560) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch560"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/arch560"); + + cbm_node_t main_fn = {0}; + main_fn.project = proj; + main_fn.label = "Function"; + main_fn.name = "main"; + main_fn.qualified_name = "arch560.cmd.main"; + main_fn.file_path = "cmd/main.go"; + main_fn.start_line = 1; + main_fn.end_line = 3; + main_fn.properties_json = "{\"is_entry_point\":true}"; + ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0); + + /* A File node so the file_tree aspect has real content — makes the + * "overview drops file_tree" assertion below non-vacuous. */ + cbm_node_t file_node = {.project = proj, + .label = "File", + .name = "main.go", + .qualified_name = "arch560.cmd.main.go", + .file_path = "cmd/main.go"}; + ASSERT_GT(cbm_store_upsert_node(st, &file_node), 0); + + /* Sanity: with "all", both entry_points and file_tree surface. */ + char *resp_all = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":560,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"all\"]}}}"); + ASSERT_NOT_NULL(resp_all); + char *inner_all = extract_text_content(resp_all); + ASSERT_NOT_NULL(inner_all); + ASSERT_NOT_NULL(strstr(inner_all, "entry_points[")); + ASSERT_NOT_NULL(strstr(inner_all, "file_tree[")); + free(inner_all); + free(resp_all); + + /* "overview": substantive content (entry_points, node_labels) but NO + * file_tree section. */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":561,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"overview\"]}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "entry_points[")); + ASSERT_NOT_NULL(strstr(inner, "node_labels[")); + ASSERT_NULL(strstr(inner, "file_tree[")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_architecture_reports_dirty_metadata_as_canonical_only) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-dirty"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/arch-dirty"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t fn = {.project = proj, + .label = "Function", + .name = "Run", + .qualified_name = "arch-dirty.Run", + .file_path = "run.c", + .properties_json = "{\"is_entry_point\":true}"}; + ASSERT_GT(cbm_store_upsert_node(st, &fn), 0); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "run.c", + .observed_hash = "arch-dirty-hash", + .observed_generation = 13, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-dirty\",\"aspects\":[\"all\"]," + "\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"entry_points\"")); + ASSERT_NOT_NULL(strstr(inner, "Run")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "get_architecture reads canonical graph summaries")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"canonical_only\"")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Distills PR #560 (server-side validation): unknown aspect tokens must be + * rejected with an isError result listing the valid values. Before the fix + * the JSON-Schema accepted any string and both aspect gates simply never + * matched, so a typo like "bogus_aspect" produced a silent near-empty payload + * with isError:false. RED on unfixed code: no isError, no "Unknown aspect". */ +TEST(tool_get_architecture_rejects_unknown_aspect_pr560) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch560v"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/arch560v"); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":562,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch560v\",\"aspects\":[\"bogus_aspect\"]}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(resp, "Unknown aspect 'bogus_aspect'")); + /* The error must teach the valid vocabulary, including the new token. */ + ASSERT_NOT_NULL(strstr(resp, "overview")); + ASSERT_NOT_NULL(strstr(resp, "file_tree")); + + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Reproduce-first for #640: query handlers must accept the `project_name` + * alias, not only the canonical `project` key. list_projects surfaces the field + * as "name" and the error hint says "pass the project name", so a caller + * naturally passes `project_name`. With no alias, the handler reads key + * "project" -> NULL -> resolve_store bails before opening any .db -> "project + * not found or not indexed" even though the project is indexed. Mirrors + * tool_get_architecture_emits_populated_sections but with the alias key. */ +TEST(tool_get_architecture_accepts_project_name_alias_issue640) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "alias640"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/alias640"); + + cbm_node_t main_fn = {0}; + main_fn.project = proj; + main_fn.label = "Function"; + main_fn.name = "main"; + main_fn.qualified_name = "alias640.cmd.main"; + main_fn.file_path = "cmd/main.go"; + main_fn.start_line = 1; + main_fn.end_line = 3; + main_fn.properties_json = "{\"is_entry_point\":true}"; + ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0); + + /* Caller passes `project_name` (the natural guess) instead of `project`. */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":640,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project_name\":\"alias640\",\"aspects\":[\"all\"]}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + /* RED before the alias: inner is the "project not found" error. + * GREEN after: the alias resolves and architecture sections surface. */ + ASSERT_NULL(strstr(inner, "project not found")); + ASSERT_NOT_NULL(strstr(inner, "entry_points[")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Reproduce-first for #640: the alias must apply across query handlers, not + * just get_architecture. search_graph with `project_name` must resolve too. */ +TEST(tool_search_graph_accepts_project_name_alias_issue640) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "alias640b"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/alias640b"); + + cbm_node_t fn = {0}; + fn.project = proj; + fn.label = "Function"; + fn.name = "WidgetHandler"; + fn.qualified_name = "alias640b.svc.WidgetHandler"; + fn.file_path = "svc/widget.go"; + fn.start_line = 1; + fn.end_line = 2; + ASSERT_GT(cbm_store_upsert_node(st, &fn), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":641,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project_name\":\"alias640b\",\"name_pattern\":\"Widget.*\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + ASSERT_NULL(strstr(inner, "project not found")); + ASSERT_NOT_NULL(strstr(inner, "WidgetHandler")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_architecture_uses_overlay_active_entry_points) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-overlay-entry"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/arch-overlay-entry"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldEntry", + .qualified_name = "arch-overlay-entry.OldEntry", + .file_path = "cmd/main.go", + .properties_json = "{\"is_entry_point\":true}"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_fn), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_fn = {.project = proj, + .label = "Function", + .name = "FreshEntry", + .qualified_name = "arch-overlay-entry.FreshEntry", + .file_path = "cmd/main.go", + .properties_json = "{\"is_entry_point\":true}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "cmd/main.go", + .generation = 1, + .nodes = &new_fn, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-overlay-entry\"," + "\"aspects\":[\"entry_points\"],\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"entry_points\"")); + ASSERT_NOT_NULL(strstr(inner, "FreshEntry")); + ASSERT_NULL(strstr(inner, "OldEntry")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"mixed_active_nodes_canonical_summaries\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_sections\":[\"entry_points\"]")); + ASSERT_NOT_NULL(strstr(inner, "freshness.active_sections")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_architecture_uses_overlay_active_routes) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-overlay-route"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/arch-overlay-route"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_route = {.project = proj, + .label = "Route", + .name = "/old-route", + .qualified_name = "arch-overlay-route.old_route", + .file_path = "cmd/main.go", + .properties_json = + "{\"method\":\"GET\",\"path\":\"/old-route\"}"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_route), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_route = {.project = proj, + .label = "Route", + .name = "/fresh-route", + .qualified_name = "arch-overlay-route.fresh_route", + .file_path = "cmd/main.go", + .properties_json = + "{\"method\":\"POST\",\"path\":\"/fresh-route\"}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "cmd/main.go", + .generation = 1, + .nodes = &fresh_route, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-overlay-route\"," + "\"aspects\":[\"routes\"],\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"routes\"")); + ASSERT_NOT_NULL(strstr(inner, "/fresh-route")); + ASSERT_NULL(strstr(inner, "/old-route")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"mixed_active_nodes_canonical_summaries\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_sections\":[\"routes\"]")); + ASSERT_NOT_NULL(strstr(inner, "freshness.active_sections")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_architecture_uses_overlay_active_file_summaries) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-overlay-files"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/arch-overlay-files"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t stale_file = {.project = proj, + .label = "File", + .name = "stale.py", + .qualified_name = "arch-overlay-files.src.stale", + .file_path = "src/stale.py", + .properties_json = "{}"}; + cbm_node_t live_file = {.project = proj, + .label = "File", + .name = "live.go", + .qualified_name = "arch-overlay-files.src.live", + .file_path = "src/live.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(st, &stale_file), 0); + ASSERT_GT(cbm_store_upsert_node(st, &live_file), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_store_file_delta_t delete_delta = {.project = proj, + .rel_path = "src/stale.py", + .generation = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delete_delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":98,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-overlay-files\",\"path\":\"src\"," + "\"aspects\":[\"languages\",\"file_tree\"],\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"languages\"")); + ASSERT_NOT_NULL(strstr(inner, "\"Go\"")); + ASSERT_NULL(strstr(inner, "\"Python\"")); + ASSERT_NOT_NULL(strstr(inner, "\"file_tree\"")); + ASSERT_NOT_NULL(strstr(inner, "src/live.go")); + ASSERT_NULL(strstr(inner, "src/stale.py")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"mixed_active_nodes_canonical_summaries\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_sections\":[\"languages\",\"file_tree\"]")); + ASSERT_NOT_NULL(strstr(inner, "freshness.active_sections")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_architecture_uses_ready_overlay_summaries) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "resource-arch-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/resource-arch-overlay"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldResourceArch", + .qualified_name = "resource.arch.OldResourceArch", + .file_path = "src/main.c", + .properties_json = "{\"is_entry_point\":true}"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_fn), 0); + cbm_node_t old_route = {.project = proj, + .label = "Route", + .name = "/old-resource-route", + .qualified_name = "resource.arch.old_route", + .file_path = "src/main.c", + .properties_json = + "{\"method\":\"GET\",\"path\":\"/old-resource-route\"}"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_route), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_nodes[] = { + {.project = proj, + .label = "Function", + .name = "FreshResourceArch", + .qualified_name = "resource.arch.FreshResourceArch", + .file_path = "src/main.c", + .properties_json = "{\"is_entry_point\":true}"}, + {.project = proj, + .label = "Route", + .name = "/fresh-resource-route", + .qualified_name = "resource.arch.fresh_route", + .file_path = "src/main.c", + .properties_json = "{\"method\":\"POST\",\"path\":\"/fresh-resource-route\"}"}, + {.project = proj, + .label = "File", + .name = "src/main.c", + .qualified_name = "resource.arch.src.main", + .file_path = "src/main.c", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = fresh_nodes, + .node_count = + (int)(sizeof(fresh_nodes) / sizeof(fresh_nodes[0]))}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://architecture\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"contents\"")); + ASSERT_NOT_NULL(strstr(resp, "FreshResourceArch")); + ASSERT_NULL(strstr(resp, "OldResourceArch")); + ASSERT_NOT_NULL(strstr(resp, "/fresh-resource-route")); + ASSERT_NULL(strstr(resp, "/old-resource-route")); + ASSERT_NOT_NULL(strstr(resp, "\\\"languages\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"entry_points\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"routes\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"read_model\\\":\\\"mixed_active_nodes_canonical_summaries\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"active_sections\\\":[\\\"languages\\\",\\\"entry_points\\\",\\\"routes\\\"]")); + ASSERT_NOT_NULL(strstr(resp, "\\\"active_file_tombstones\\\":1")); + /* relationship_patterns moved to the overlay-aware selector (ISSUE-4); + * the disclosure must list only the summaries that stay canonical. */ + ASSERT_NOT_NULL(strstr(resp, "routes, and relationship_patterns")); + ASSERT_NOT_NULL(strstr(resp, "total_nodes, total_edges, and key_functions")); + + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_report_stale_architecture_and_omit_rank_values) { + char config_dir[CBM_PATH_MAX]; + ASSERT_TRUE(snprintf(config_dir, sizeof(config_dir), "/tmp/cbm-mcp-resource-stale-XXXXXX") > 0); + ASSERT_NOT_NULL(cbm_mkdtemp(config_dir)); + cbm_config_t *config = cbm_config_open(config_dir); + ASSERT_NOT_NULL(config); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_RANK_REFRESH, CBM_RANK_REFRESH_AT_PUBLISH), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, config); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "resource-arch-stale"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/resource-arch-stale"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t fn = {.project = proj, + .label = "Function", + .name = "StaleRank", + .qualified_name = "resource.arch.StaleRank", + .file_path = "src/main.c"}; + int64_t node_id = cbm_store_upsert_node(st, &fn); + ASSERT_GT(node_id, 0); + char rank_sql[CBM_SZ_512]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('%s',%lld,0.99,'2026-07-27T00:00:00Z')", + proj, (long long)node_id); + ASSERT_EQ(cbm_store_exec(st, rank_sql), CBM_STORE_OK); + const char *stale_views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_VIEW_ARCHITECTURE}; + ASSERT_EQ(cbm_store_mark_derived_views_stale( + st, proj, CBM_STORE_DERIVED_GENERATION_UNKNOWN, stale_views, + (int)(sizeof(stale_views) / sizeof(stale_views[0]))), + CBM_STORE_OK); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":102,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://architecture\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "pagerank derived view is stale")); + ASSERT_NOT_NULL(strstr(resp, "architecture derived view is stale")); + ASSERT_NOT_NULL(strstr(resp, "\\\"freshness\\\":{\\\"state\\\":\\\"stale_with_warning\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"action_required\\\"")); + ASSERT_NOT_NULL(strstr(resp, CBM_CONFIG_RANK_REFRESH)); + ASSERT_NOT_NULL(strstr(resp, "index_repository")); + ASSERT_NOT_NULL(strstr(resp, "requires rank refresh during publication")); + ASSERT_NULL(strstr(resp, "permits deferred")); + ASSERT_NULL(strstr(resp, "\\\"key_functions\\\"")); + ASSERT_NULL(strstr(resp, "0.99")); + free(resp); + + resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":103,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "pagerank derived view is stale")); + ASSERT_NOT_NULL(strstr(resp, "architecture derived view is stale")); + ASSERT_NOT_NULL(strstr(resp, "\\\"freshness\\\":{\\\"state\\\":\\\"stale_with_warning\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"action_required\\\"")); + ASSERT_NOT_NULL(strstr(resp, CBM_CONFIG_RANK_REFRESH)); + ASSERT_NOT_NULL(strstr(resp, "index_repository")); + ASSERT_NOT_NULL(strstr(resp, "requires rank refresh during publication")); + ASSERT_NULL(strstr(resp, "permits deferred")); + ASSERT_NULL(strstr(resp, "\\\"ranked_nodes\\\"")); + ASSERT_NULL(strstr(resp, "\\\"pagerank_computed_at\\\"")); + free(resp); + + ASSERT_EQ(th_set_raw_config_value(config_dir, CBM_CONFIG_RANK_REFRESH, "invalid-policy"), 0); + resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":104,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "rank_refresh=invalid-policy is invalid")); + ASSERT_NOT_NULL(strstr(resp, "falls back to at_publish")); + ASSERT_NOT_NULL(strstr(resp, "config set rank_refresh at_publish")); + ASSERT_NULL(strstr(resp, "\\\"ranked_nodes\\\"")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(config); + char config_path[CBM_PATH_MAX]; + ASSERT_TRUE(snprintf(config_path, sizeof(config_path), "%s/_config.db", config_dir) > 0); + cbm_remove_db_sidecars(config_path); + cbm_unlink(config_path); + cbm_rmdir(config_dir); + PASS(); +} + +TEST(resource_schema_uses_ready_overlay_counts) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "resource-schema-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/resource-schema-overlay"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldResourceSchema", + .qualified_name = "resource.schema.OldResourceSchema", + .file_path = "src/main.c"}; + ASSERT_GT(cbm_store_upsert_node(st, &old_fn), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_class = {.project = proj, + .label = "Class", + .name = "FreshResourceSchema", + .qualified_name = "resource.schema.FreshResourceSchema", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &fresh_class, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://schema\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"contents\"")); + ASSERT_NULL(strstr(resp, "\\\"label\\\":\\\"Function\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"label\\\":\\\"Class\\\"")); + ASSERT_NOT_NULL(strstr(resp, "codebase://schema used active overlay node and edge rows")); + ASSERT_NOT_NULL(strstr(resp, "\\\"read_model\\\":\\\"overlay_active_graph\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"active_sections\\\":[\\\"node_labels\\\",\\\"edge_types\\\"]")); + ASSERT_NOT_NULL(strstr(resp, "\\\"active_file_tombstones\\\":1")); + + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* T15 (schema call-graph audit 2026-07-19): codebase://architecture's + * relationship_patterns must come from the overlay-aware selector. RED + * against the pre-fix build_resource_architecture, which read canonical-only + * cbm_store_get_schema and would advertise a (Function)-[CALLS]->(Class) + * pattern whose only source row is tombstoned in the active overlay. */ +TEST(resource_arch_rel_patterns_use_ready_overlay) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "resource-arch-patterns-overlay"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/resource-arch-patterns-overlay"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_fn = {.project = proj, + .label = "Function", + .name = "OldPatternSource", + .qualified_name = "resource.arch.patterns.OldPatternSource", + .file_path = "src/main.c"}; + cbm_node_t stable = {.project = proj, + .label = "Class", + .name = "StablePatternTarget", + .qualified_name = "resource.arch.patterns.StablePatternTarget", + .file_path = "src/stable.c"}; + int64_t old_fn_id = cbm_store_upsert_node(st, &old_fn); + int64_t stable_id = cbm_store_upsert_node(st, &stable); + ASSERT_GT(old_fn_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_fn_id, + .target_id = stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh_class = {.project = proj, + .label = "Class", + .name = "FreshPatternSource", + .qualified_name = "resource.arch.patterns.FreshPatternSource", + .file_path = "src/main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t fresh_edge = {.source_qn = "resource.arch.patterns.FreshPatternSource", + .target_qn = "resource.arch.patterns.StablePatternTarget", + .type = "HANDLES", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "src/main.c", + .generation = 1, + .nodes = &fresh_class, + .node_count = 1, + .edges = &fresh_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":101,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://architecture\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"contents\"")); + /* Active-view pattern present; tombstoned-source pattern absent. */ + ASSERT_NOT_NULL(strstr(resp, "HANDLES")); + ASSERT_NULL(strstr(resp, "CALLS")); + + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* #1025: agents pass the repo FOLDER name ("codebase-memory-mcp"), but + * indexed project names derive from the full path + * (E:\project\graph\x -> "E-project-graph-x"), so exact lookup fails with + * "project not found" while list_projects clearly shows the project. A + * passed name that matches exactly ONE indexed project as a segment-aligned + * tail ("-" suffix) must resolve to it; zero or several matches keep + * the existing error. Runs against real cache-dir .db files (the resolution + * scans filenames), so this test indexes real fixtures under an overridden + * CBM_CACHE_DIR. */ +static void i1025_write_repo(const char *dir, const char *fn_name) { + char path[CBM_SZ_512]; + snprintf(path, sizeof(path), "%s/mod.py", dir); + FILE *f = fopen(path, "w"); + if (!f) + return; + fprintf(f, "def %s(x):\n return x + 1\n", fn_name); + fclose(f); +} + +TEST(tool_project_arg_resolves_unique_tail_issue1025) { + char repo_a[CBM_SZ_256]; + char repo_b[CBM_SZ_256]; + char repo_c[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(repo_a, sizeof(repo_a), "/tmp/cbm-i1025a-XXXXXX"); + snprintf(repo_b, sizeof(repo_b), "/tmp/cbm-i1025b-XXXXXX"); + snprintf(repo_c, sizeof(repo_c), "/tmp/cbm-i1025c-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-i1025d-XXXXXX"); + if (!cbm_mkdtemp(repo_a) || !cbm_mkdtemp(repo_b) || !cbm_mkdtemp(repo_c) || + !cbm_mkdtemp(cache)) { + FAIL("mkdtemp failed"); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); + + i1025_write_repo(repo_a, "unique_tail_target"); + i1025_write_repo(repo_b, "amb_one"); + i1025_write_repo(repo_c, "amb_two"); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[CBM_SZ_1K]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"E-project-graph-suffix1025\"}", + repo_a); + char *r = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(r); + free(r); + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"F-alpha-amb1025\"}", repo_b); + r = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(r); + free(r); + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"G-beta-amb1025\"}", repo_c); + r = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(r); + free(r); + + /* 1. Unique tail resolves (RED today: "project not found"). */ + r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"suffix1025\",\"name_pattern\":\".*target.*\"}"); + ASSERT_NOT_NULL(r); + if (strstr(r, "project not found")) { + fprintf(stderr, " [1025] FAIL unique tail did not resolve: %.200s\n", r); + } + ASSERT_NULL(strstr(r, "project not found")); + ASSERT_NOT_NULL(strstr(r, "unique_tail_target")); + free(r); + + /* 2. Ambiguous tail stays an error (never guess between projects). */ + r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"amb1025\",\"name_pattern\":\".*\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "project not found")); + free(r); + + /* 3. Exact full name keeps working unchanged. */ + r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"E-project-graph-suffix1025\"," + "\"name_pattern\":\".*target.*\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NULL(strstr(r, "project not found")); + free(r); + + cbm_mcp_server_free(srv); + if (saved_cache_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1); + free(saved_cache_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(repo_a); + th_rmtree(repo_b); + th_rmtree(repo_c); + th_rmtree(cache); + PASS(); +} + +/* Regression for #604: path scopes architecture totals and content. */ +TEST(tool_get_architecture_path_scoping) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + const char *proj = "arch-path"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/arch-path"); + + cbm_node_t pkg_global = {.project = proj, + .label = "Package", + .name = "Django", + .qualified_name = "arch-path.Django", + .file_path = "vendor/django/__init__.py"}; + cbm_store_upsert_node(st, &pkg_global); + + cbm_node_t pkg_local = {.project = proj, + .label = "Package", + .name = "hoa", + .qualified_name = "arch-path.hoa", + .file_path = "apps/hoa/main.go"}; + cbm_store_upsert_node(st, &pkg_local); + + cbm_node_t f_hoa = {.project = proj, + .label = "File", + .name = "main.go", + .qualified_name = "arch-path.apps.hoa.main.go", + .file_path = "apps/hoa/main.go"}; + cbm_store_upsert_node(st, &f_hoa); + + cbm_node_t f_other = {.project = proj, + .label = "File", + .name = "other.go", + .qualified_name = "arch-path.other.go", + .file_path = "lib/other.go"}; + cbm_store_upsert_node(st, &f_other); + + cbm_node_t local_key = {.project = proj, + .label = "Function", + .name = "LocalKeyFunction", + .qualified_name = "arch-path.apps.hoa.LocalKeyFunction", + .file_path = "apps/hoa/main.go"}; + int64_t local_key_id = cbm_store_upsert_node(st, &local_key); + ASSERT_GT(local_key_id, 0); + cbm_node_t global_key = {.project = proj, + .label = "Function", + .name = "GlobalKeyFunction", + .qualified_name = "arch-path.scripts.GlobalKeyFunction", + .file_path = "scripts/helpers.py"}; + int64_t global_key_id = cbm_store_upsert_node(st, &global_key); + ASSERT_GT(global_key_id, 0); + char rank_sql[512]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) VALUES " + "('arch-path',%lld,0.8,'2026-07-15T00:00:00Z')," + "('arch-path',%lld,0.9,'2026-07-15T00:00:00Z')", + (long long)local_key_id, (long long)global_key_id); + ASSERT_EQ(cbm_store_exec(st, rank_sql), CBM_STORE_OK); + + char *resp_root = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-path\",\"aspects\":[\"packages\"]}}}"); + ASSERT_NOT_NULL(resp_root); + char *inner_root = extract_text_content(resp_root); + ASSERT_NOT_NULL(inner_root); + ASSERT_NOT_NULL(strstr(inner_root, "Django")); + + char *resp_scoped = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\"," + "\"aspects\":[\"packages\"]}}}"); + ASSERT_NOT_NULL(resp_scoped); + char *inner_scoped = extract_text_content(resp_scoped); + ASSERT_NOT_NULL(inner_scoped); + + ASSERT_NOT_NULL(strstr(inner_scoped, "root_total_nodes")); + ASSERT_NOT_NULL(strstr(inner_scoped, "scoped_total_nodes")); + ASSERT_NOT_NULL(strstr(inner_scoped, "path: ")); + ASSERT_NOT_NULL(strstr(inner_scoped, "hoa")); + ASSERT_NULL(strstr(inner_scoped, "Django")); + + int root_nodes = 0; + int scoped_nodes = 0; + /* TOON scalar form (`key: N`) with JSON fallback for format:"json". */ + const char *rt = strstr(inner_scoped, "root_total_nodes: "); + const char *stn = strstr(inner_scoped, "scoped_total_nodes: "); + if (rt) { + sscanf(rt, "root_total_nodes: %d", &root_nodes); + } else if ((rt = strstr(inner_scoped, "\"root_total_nodes\":")) != NULL) { + sscanf(rt, "\"root_total_nodes\":%d", &root_nodes); + } + if (stn) { + sscanf(stn, "scoped_total_nodes: %d", &scoped_nodes); + } else if ((stn = strstr(inner_scoped, "\"scoped_total_nodes\":")) != NULL) { + sscanf(stn, "\"scoped_total_nodes\":%d", &scoped_nodes); + } + ASSERT_TRUE(root_nodes > scoped_nodes); + ASSERT_TRUE(scoped_nodes > 0); + + char *resp_scoped_json = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_architecture\"," + "\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\"," + "\"aspects\":[\"packages\"],\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp_scoped_json); + char *inner_scoped_json = extract_text_content(resp_scoped_json); + ASSERT_NOT_NULL(inner_scoped_json); + ASSERT_NOT_NULL(strstr(inner_scoped_json, "LocalKeyFunction")); + ASSERT_NULL(strstr(inner_scoped_json, "GlobalKeyFunction")); + + free(inner_scoped_json); + free(resp_scoped_json); + free(inner_scoped); + free(resp_scoped); + free(inner_root); + free(resp_root); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_query_graph_missing_query) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":23,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"query_graph\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + /* Should return error about missing query */ + ASSERT_NOT_NULL(strstr(resp, "required")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * PIPELINE-DEPENDENT TOOL HANDLERS + * ══════════════════════════════════════════════════════════════════ */ + +TEST(tool_index_repository_missing_path) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "required")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_index_repository_auto_index_deps_arg_disables_deps) { + char *repo_tmp = th_mktempdir("cbm_mcp_dep_arg_repo"); + ASSERT_NOT_NULL(repo_tmp); + char repo[CBM_PATH_MAX]; + int n = snprintf(repo, sizeof(repo), "%s", repo_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(repo)); + + char *cache_tmp = th_mktempdir("cbm_mcp_dep_arg_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "true"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_DEP_LIMIT, "5"), 0); + + char vendor_dir[CBM_PATH_MAX]; + n = snprintf(vendor_dir, sizeof(vendor_dir), "%s/vendor/libdep", repo); + ASSERT(n >= 0 && (size_t)n < sizeof(vendor_dir)); + ASSERT_EQ(th_mkdir_p(vendor_dir), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "Makefile"), "all:\n\tcc main.c\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "main.c"), "int main(void) { return 0; }\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(vendor_dir, "lib.c"), "int libdep(void) { return 1; }\n"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char req[CBM_SZ_4K]; + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"," + "\"auto_index_deps\":false}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + ASSERT_NULL(strstr(resp, "dependencies_indexed")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_cleanup(repo); + th_cleanup(cache); + PASS(); +} + +TEST(tool_index_repository_exact_moderate_preserves_semantic_stale_state) { + char *repo_tmp = th_mktempdir("cbm_mcp_semantic_stale_repo"); + ASSERT_NOT_NULL(repo_tmp); + char repo[CBM_PATH_MAX]; + int n = snprintf(repo, sizeof(repo), "%s", repo_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(repo)); + char *cache_tmp = th_mktempdir("cbm_mcp_semantic_stale_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_REINDEX, + CBM_CONFIG_INCREMENTAL_REINDEX_ALWAYS), + 0); + ASSERT_EQ(cbm_config_set( + cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFER_ALL_INCREMENTAL_REINDEXES), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_OFF), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_ENABLED, "false"), 0); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "records.py"), + "def normalize_user(value):\n" + " return value.strip().lower()\n\n" + "def normalize_account(value):\n" + " return value.strip().lower()\n"), + 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char args[CBM_SZ_4K]; + n = snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"moderate\"," + "\"auto_index_deps\":false,\"format\":\"json\"}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(args)); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"status\":\"indexed\"")); + free(resp); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "records.py"), + "def normalize_user(value):\n" + " return value.strip().lower()\n\n" + "def normalize_account(value):\n" + " return value.strip().lower()\n\n" + "def normalize_team(value):\n" + " return value.strip().lower()\n"), + 0); + resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"publish_kind\":\"incremental_exact\"")); + free(resp); + + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(db_path, sizeof(db_path), cache, project), CBM_STORE_OK); + cbm_store_t *store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + ASSERT_TRUE(cbm_store_derived_view_is_stale(store, project, + CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES)); + cbm_store_close(store); + + n = snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"query\":\"MATCH (a)-[:SEMANTICALLY_RELATED]->(b) " + "RETURN a.name, b.name LIMIT 5\",\"format\":\"json\"}", + project); + ASSERT(n >= 0 && (size_t)n < sizeof(args)); + resp = cbm_mcp_handle_tool(srv, "query_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "semantic_edges derived view is stale")); + free(resp); + + free(project); + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + mcp_restore_cache_dir(saved_copy); + th_cleanup(repo); + th_cleanup(cache); + PASS(); +} + +TEST(tool_index_repository_auto_dep_limit_arg_caps_deps) { + char *repo_tmp = th_mktempdir("cbm_mcp_dep_limit_repo"); + ASSERT_NOT_NULL(repo_tmp); + char repo[CBM_PATH_MAX]; + int n = snprintf(repo, sizeof(repo), "%s", repo_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(repo)); + + char *cache_tmp = th_mktempdir("cbm_mcp_dep_limit_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "true"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_DEP_LIMIT, "5"), 0); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "Makefile"), "all:\n\tcc main.c\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "main.c"), "int main(void) { return 0; }\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "vendor/liba/liba.c"), "int liba(void) { return 1; }\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "vendor/libb/libb.c"), "int libb(void) { return 2; }\n"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char req[CBM_SZ_4K]; + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"," + "\"auto_dep_limit\":1,\"format\":\"json\"}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + ASSERT_NOT_NULL(strstr(resp, "\\\"dependencies_indexed\\\":1")); + ASSERT_NOT_NULL(strstr(resp, "\\\"dependency_auto_index\\\"")); + ASSERT_NOT_NULL(strstr(resp, "\\\"package_limit\\\":1")); + ASSERT_NOT_NULL(strstr(resp, "\\\"candidates_observed\\\":2")); + ASSERT_NOT_NULL(strstr(resp, "\\\"packages_selected\\\":1")); + ASSERT_NOT_NULL(strstr(resp, "\\\"package_limit_hit\\\":true")); + ASSERT_NOT_NULL(strstr(resp, "index_dependencies")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_cleanup(repo); + th_cleanup(cache); + PASS(); +} + +TEST(tool_index_repository_reports_dependency_file_limit_skip) { + char *repo = th_mktempdir("cbm_mcp_dep_file_limit_repo"); + ASSERT_NOT_NULL(repo); + char *cache = th_mktempdir("cbm_mcp_dep_file_limit_cache"); + ASSERT_NOT_NULL(cache); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_DEPS, "true"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_DEP_MAX_FILES, "1"), 0); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "Makefile"), "all:\n\tcc main.c\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "main.c"), "int main(void) { return 0; }\n"), 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "vendor/liba/first.c"), + "int first(void) { return 1; }\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "vendor/liba/second.c"), + "int second(void) { return 2; }\n"), + 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char req[CBM_SZ_4K]; + int n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"," + "\"format\":\"json\"}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\\\"dependency_file_limit\\\":1")); + ASSERT_NOT_NULL(strstr(resp, "\\\"packages_skipped_file_limit\\\":1")); + ASSERT_NOT_NULL(strstr(resp, "\\\"package_limit_hit\\\":false")); + ASSERT_NOT_NULL(strstr(resp, "index_dependencies")); + ASSERT_NOT_NULL(strstr(resp, "dep_max_files")); + free(resp); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_cleanup(repo); + th_cleanup(cache); + PASS(); +} + +TEST(tool_index_repository_after_publish_starts_overlay_compaction_worker) { + char *repo_tmp = th_mktempdir("cbm_mcp_overlay_trigger_repo"); + ASSERT_NOT_NULL(repo_tmp); + char repo[CBM_PATH_MAX]; + int n = snprintf(repo, sizeof(repo), "%s", repo_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(repo)); + + char *cache_tmp = th_mktempdir("cbm_mcp_overlay_trigger_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_REINDEX, + CBM_CONFIG_INCREMENTAL_REINDEX_ALWAYS), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_COMPACTION_POLICY, + CBM_CONFIG_OVERLAY_COMPACTION_POLICY_AFTER_PUBLISH), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_COMPACTION_MAX_GENERATIONS, + CBM_CONFIG_OVERLAY_COMPACTION_DEFAULT_MAX_GENERATIONS), + 0); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "go.mod"), "module example.com/overlaytrigger\n\n" + "go 1.22\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "main.go"), + "package main\n\nfunc main() {\n\tHelper()\n}\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "helper.go"), + "package main\n\nfunc Helper() int {\n\treturn 1\n}\n"), + 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char req[CBM_SZ_4K]; + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + free(resp); + + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); + ASSERT_EQ(th_write_file(TH_PATH(repo, "helper.go"), + "package main\n\nfunc Helper() int {\n\treturn 2\n}\n\n" + "func OverlayTriggerOnly() int {\n\treturn 44\n}\n"), + 0); + + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":45,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"publish_kind\":\"incremental_overlay\"")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_compaction_policy\":\"after_publish\"")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_compaction_max_generations\":1")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_compaction_started\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_compaction_status\":\"started\"")); + free(inner); + free(resp); + + int compacted = -1; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), CBM_STORE_OK); + ASSERT_EQ(compacted, 1); + + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(db_path, sizeof(db_path), cache, project), + CBM_STORE_OK); + cbm_store_t *store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + cbm_store_overlay_node_view_summary_t summary = {0}; + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(store, project, &summary), + CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 0); + int pending = -1; + int overlay_ready = -1; + ASSERT_EQ(cbm_store_count_dirty_files(store, project, &pending, &overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(pending, 0); + ASSERT_EQ(overlay_ready, 0); + ASSERT_EQ(mcp_store_node_name_count(store, project, "OverlayTriggerOnly"), 1); + cbm_store_close(store); + + free(project); + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + mcp_restore_cache_dir(saved_copy); + th_cleanup(repo); + th_cleanup(cache); + PASS(); +} + +TEST(tool_index_repository_reports_incremental_containment_reason) { + char *repo_tmp = th_mktempdir("cbm_mcp_publish_reason_repo"); + if (!repo_tmp) { + PASS(); + } + char repo[CBM_PATH_MAX]; + int n = snprintf(repo, sizeof(repo), "%s", repo_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(repo)); + + char *cache_tmp = th_mktempdir("cbm_mcp_publish_reason_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_config_t *cfg = cbm_config_open(cache); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_REINDEX, "always"), 0); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "go.mod"), "module example.com/pubreason\n\ngo 1.22\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "main.go"), + "package main\n\nfunc main() {\n\tHelper()\n\tLeaf()\n}\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "helper.go"), + "package main\n\nfunc Helper() int {\n\treturn 1\n}\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "leaf.go"), + "package main\n\nfunc Leaf() int {\n\treturn 2\n}\n"), + 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char req[CBM_SZ_4K]; + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":41,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + free(resp); + + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":411,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"%s\",\"query\":\"Helper\",\"limit\":5," + "\"format\":\"json\"}}}", + project); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\"")); + ASSERT_NOT_NULL(strstr(inner, "Helper")); + free(inner); + free(resp); + free(project); + + ASSERT_EQ(th_write_file(TH_PATH(repo, "main.go"), + "package main\n\nfunc main() {\n\tHelper()\n\tLeaf()\n}\n\n" + "func NewMain() int {\n\treturn 11\n}\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "helper.go"), + "package main\n\nfunc Helper() int {\n\treturn 3\n}\n\n" + "func NewHelper() int {\n\treturn 13\n}\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(repo, "leaf.go"), + "package main\n\nfunc Leaf() int {\n\treturn 5\n}\n\n" + "func NewLeaf() int {\n\treturn 17\n}\n"), + 0); + + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"}}}", + repo); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"publish_kind\":\"incremental_containment\"")); + ASSERT_NOT_NULL(strstr(inner, "\"publish_reason\":\"changed_batch_too_large\"")); + ASSERT_NOT_NULL(strstr(inner, "\"exact_delta\"")); + ASSERT_NOT_NULL(strstr(inner, "\"changed_paths\":3")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_cleanup(repo); + th_cleanup(cache); + PASS(); +} + +TEST(tool_get_code_snippet_missing_qn) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_code_snippet\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "required")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_get_code_snippet_not_found) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":32,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_code_snippet\"," + "\"arguments\":{\"qualified_name\":\"nonexistent.func\"," + "\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not found")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_search_code_missing_pattern) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":33,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "required")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_search_code_no_project) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":34,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"func main\"," + "\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + /* No project indexed → error */ + ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed") || + strstr(resp, "required")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_multi_word) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Multi-word query "HandleRequest error" — should find the line + * "func HandleRequest() error {" via regex conversion. */ + char req[512]; + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest error\"," + "\"project\":\"test-project\"}}}"); + + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + /* Should find at least one result (not zero) */ + ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL); + /* Should NOT contain an error about "not found" */ + ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + free(resp); + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_preserves_valid_utf8_source) { + static const char expected[] = "caf\xC3\xA9 \xE2\x80\x94 \xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"; + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char src_path[512]; + int n = snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(src_path)); + ASSERT_EQ(th_write_file(src_path, "package main\n" + "\n" + "func HandleRequest() error {\n" + "\t// localized caf\xC3\xA9 \xE2\x80\x94 " + "\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E\n" + "\treturn nil\n" + "}\n"), + 0); + + char *resp = cbm_mcp_handle_tool(srv, "search_code", + "{\"pattern\":\"localized\",\"project\":\"test-project\"," + "\"mode\":\"full\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + yyjson_doc *inner_doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(inner_doc); + yyjson_doc_free(inner_doc); + ASSERT_NOT_NULL(strstr(inner, expected)); + + free(inner); + free(resp); + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_reports_resolved_project_for_empty_json_and_toon_results) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* The session project may differ from an explicitly queried project. Empty + * results must identify the project actually searched so a valid-but-wrong + * project selection is distinguishable from "no matching code". */ + cbm_mcp_server_set_session_project(srv, "different-session-project"); + + const char *formats[] = {"json", "toon"}; + for (size_t i = 0; i < sizeof(formats) / sizeof(formats[0]); i++) { + char args[512]; + int n = snprintf(args, sizeof(args), + "{\"pattern\":\"definitely_absent_symbol\"," + "\"project\":\"test-project\",\"format\":\"%s\"}", + formats[i]); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(args)); + + char *resp = cbm_mcp_handle_tool(srv, "search_code", args); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + if (strcmp(formats[i], "json") == 0) { + ASSERT_NOT_NULL(strstr(inner, "\"project\":\"test-project\"")); + ASSERT_NOT_NULL( + strstr(inner, "\"session_project\":\"different-session-project\"")); + ASSERT_NOT_NULL(strstr(inner, "\"_context\"")); + ASSERT_NOT_NULL(strstr(inner, "\"project\":\"test-project\"")); + } else { + ASSERT_NOT_NULL(strstr(inner, "project: test-project")); + ASSERT_NOT_NULL(strstr(inner, "session_project: different-session-project")); + ASSERT_NULL(strstr(inner, "_context_status")); + } + free(inner); + free(resp); + } + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_reports_dirty_graph_metadata_without_hiding_live_matches) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + cbm_dirty_file_state_t dirty = {.project = "test-project", + .rel_path = "main.go", + .observed_hash = "search-code-dirty-hash", + .observed_generation = 16, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(st, &dirty), CBM_STORE_OK); + int pending = 0; + int overlay_ready = 0; + ASSERT_EQ(cbm_store_count_dirty_files(st, "test-project", &pending, &overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(pending, 1); + ASSERT_EQ(overlay_ready, 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + pending = 0; + overlay_ready = 0; + ASSERT_EQ(cbm_store_count_dirty_files(cbm_mcp_server_store(srv), "test-project", &pending, + &overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(pending, 1); + ASSERT_EQ(overlay_ready, 0); + ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); + ASSERT_NOT_NULL(strstr(inner, "\"warnings\"")); + ASSERT_NOT_NULL(strstr(inner, "search_code reads live source files")); + ASSERT_TRUE(has_dirty_freshness_counts(inner, 1, 0)); + + free(inner); + free(resp); + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_uses_overlay_active_nodes_for_graph_annotations) { + enum { BASE_GENERATION = 1 }; + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + char src_path[512]; + int n = snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(src_path)); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + ASSERT_GT(fprintf(fp, "package main\n" + "\n" + "func FreshHandle() error {\n" + "\treturn nil\n" + "}\n"), + 0); + ASSERT_EQ(fclose(fp), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, "test-project", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t fresh = {.project = "test-project", + .label = "Function", + .name = "FreshHandle", + .qualified_name = "test-project.cmd.server.main.FreshHandle", + .file_path = "main.go", + .start_line = 3, + .end_line = 5, + .properties_json = "{\"signature\":\"func FreshHandle() error\"}"}; + cbm_store_file_delta_t delta = {.project = "test-project", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &fresh, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"FreshHandle\",\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "FreshHandle")); + ASSERT_NOT_NULL(strstr(inner, "\"qualified_name\":\"test-project.cmd.server.main.FreshHandle\"")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_nodes\"")); + ASSERT_NOT_NULL(strstr(inner, "\"active_file_tombstones\":1")); + ASSERT_NULL(strstr(inner, "test-project.cmd.server.main.HandleRequest")); + + free(inner); + free(resp); + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_limit_zero_uses_config_default) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":190,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\"," + "\"project\":\"test-project\",\"limit\":0,\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + ASSERT_NOT_NULL(results); + ASSERT_GT(yyjson_arr_size(results), 0); + + yyjson_doc_free(doc); + free(inner); + free(resp); + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_files_mode_names_each_summary_count_unit) { + char tmp[512], src_path[768], vendor_path[768]; + cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), + vendor_path, sizeof(vendor_path)); + ASSERT_NOT_NULL(srv); + + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":191,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," + "\"mode\":\"files\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(response); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *files = yyjson_obj_get(root, "files"); + ASSERT_TRUE(yyjson_is_arr(files)); + ASSERT_EQ(yyjson_arr_size(files), 2); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "returned_file_count")), 2); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "total_grep_matches")), 2); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "correlated_symbol_count")), 2); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "uncorrelated_source_match_count")), 0); + /* Backward-compatible counters retain their existing values. */ + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "total_results")), 2); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "raw_match_count")), 0); + + yyjson_doc_free(doc); + free(inner); + free(response); + cbm_mcp_server_free(srv); + cleanup_prefilter_dir(tmp, src_path, vendor_path); + PASS(); +} + +/* Reproduce-first (#687): scoped content search over a repo whose ROOT PATH + * contains a space. write_scoped_filelist emits "/" records that the + * Unix pipeline pipes to grep via xargs. With plain `xargs` (newline-split) the + * space splits one path into several bogus args -> grep finds nothing -> + * total_grep_matches == 0 (RED on the unfixed code). The fix writes NUL-separated + * records + uses `xargs -0`, so the path stays a single argument -> match found + * (GREEN). On Windows the scoped path uses PowerShell Get-Content -LiteralPath, + * which already handles spaces, so this asserts correct behavior there too. */ +TEST(search_code_scoped_path_with_spaces_issue687) { + char tmp[512]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_srch_space_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("cbm_mkdtemp failed"); + } + + /* Project root deliberately contains a space. */ + char proj_dir[640]; + snprintf(proj_dir, sizeof(proj_dir), "%s/my project", tmp); + cbm_mkdir(proj_dir); + + char src_path[768]; + snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir); + FILE *fp = fopen(src_path, "w"); + if (!fp) { + rmdir(proj_dir); + rmdir(tmp); + FAIL("cannot write source file under spaced path"); + } + fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); + fclose(fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "space-search"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, proj_dir); + + /* A node so the file is "indexed" (cbm_store_list_files -> scoped grep path) + * and the grep hit classifies to a result. */ + cbm_node_t n = {.project = proj, + .label = "Function", + .name = "HandleRequest", + .qualified_name = "space-search.main.HandleRequest", + .file_path = "main.go", + .start_line = 3, + .end_line = 5}; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"space-search\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + /* grep must have found the match despite the space in the root path. */ + int grep_matches = -1; + const char *g = strstr(inner, "\"total_grep_matches\":"); + if (g) { + sscanf(g, "\"total_grep_matches\":%d", &grep_matches); + } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { + /* TOON scalar form — the search_code compact default. */ + sscanf(g, "total_grep_matches: %d", &grep_matches); + } + ASSERT_TRUE(grep_matches > 0); + /* Scanner rows are absolute on this PowerShell path, but MCP results must + * remain project-relative after Windows canonicalization normalizes the + * root spelling. Leaking proj_dir here catches slash/case drift between + * the canonical root and Select-String output. */ + ASSERT_NOT_NULL(strstr(inner, "main.go")); + ASSERT_NULL(strstr(inner, proj_dir)); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + unlink(src_path); + rmdir(proj_dir); + rmdir(tmp); + PASS(); +} + +#ifdef _WIN32 +/* Issue #903 follow-up: scoped search_code on Windows writes a UTF-8 filelist + * containing absolute source paths, then reads it back through PowerShell. + * Windows PowerShell 5.1 treats UTF-8 without BOM as ANSI unless told + * otherwise, so a non-ASCII project root can be mojibaked before + * Select-String sees the LiteralPath. */ +TEST(search_code_scoped_path_with_cjk_root_issue903) { + char tmp[512]; + snprintf(tmp, sizeof(tmp), "%s/cbm_srch_cjk_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmp)) { + FAIL("cbm_mkdtemp failed"); + } + + char proj_dir[640]; + snprintf(proj_dir, sizeof(proj_dir), "%s/%s", tmp, + "\xE4\xB8\xAD\xE6\x96\x87\xE9\xA1\xB9\xE7\x9B\xAE"); + if (!cbm_mkdir_p(proj_dir, 0755)) { + cbm_rmdir(tmp); + FAIL("cannot create CJK project dir"); + } + + char src_path[768]; + snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir); + FILE *fp = cbm_fopen(src_path, "wb"); + if (!fp) { + cbm_rmdir(proj_dir); + cbm_rmdir(tmp); + FAIL("cannot write source file under CJK path"); + } + fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); + fclose(fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "cjk-search"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, proj_dir); + + cbm_node_t n = {.project = proj, + .label = "Function", + .name = "HandleRequest", + .qualified_name = "cjk-search.main.HandleRequest", + .file_path = "main.go", + .start_line = 3, + .end_line = 5}; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":903,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"cjk-search\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + int grep_matches = -1; + const char *g = strstr(inner, "\"total_grep_matches\":"); + if (g) { + sscanf(g, "\"total_grep_matches\":%d", &grep_matches); + } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { + /* TOON scalar form — the search_code compact default. */ + sscanf(g, "total_grep_matches: %d", &grep_matches); + } + ASSERT_TRUE(grep_matches > 0); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cbm_unlink(src_path); + cbm_rmdir(proj_dir); + cbm_rmdir(tmp); + PASS(); +} +#endif + +/* Shared fixture for the path_filter prefilter tests (PR #756 distilled): + * a project with two indexed files that both contain the search pattern — + * src/handler.go (inside the filter) and vendor/other.go (outside it). */ +static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path, + size_t src_sz, char *vendor_path, + size_t vendor_sz) { + snprintf(tmp, tmp_sz, "/tmp/cbm_srch_pref_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return NULL; + } + char dir[640]; + snprintf(dir, sizeof(dir), "%s/src", tmp); + cbm_mkdir(dir); + snprintf(dir, sizeof(dir), "%s/vendor", tmp); + cbm_mkdir(dir); + + snprintf(src_path, src_sz, "%s/src/handler.go", tmp); + snprintf(vendor_path, vendor_sz, "%s/vendor/other.go", tmp); + FILE *fp = fopen(src_path, "w"); + if (!fp) { + return NULL; + } + fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); + fclose(fp); + fp = fopen(vendor_path, "w"); + if (!fp) { + return NULL; + } + fprintf(fp, "package vendored\n\nfunc HandleRequest() error {\n\treturn nil\n}\n"); + fclose(fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) { + return NULL; + } + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "prefilter-search"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, tmp); + + cbm_node_t n1 = {.project = proj, + .label = "Function", + .name = "HandleRequest", + .qualified_name = "prefilter-search.main.HandleRequest", + .file_path = "src/handler.go", + .start_line = 3, + .end_line = 5}; + cbm_node_t n2 = {.project = proj, + .label = "Function", + .name = "HandleRequest", + .qualified_name = "prefilter-search.vendored.HandleRequest", + .file_path = "vendor/other.go", + .start_line = 3, + .end_line = 5}; + if (cbm_store_upsert_node(st, &n1) <= 0 || cbm_store_upsert_node(st, &n2) <= 0) { + cbm_mcp_server_free(srv); + return NULL; + } + return srv; +} + +static void cleanup_prefilter_dir(const char *tmp, const char *src_path, const char *vendor_path) { + char dir[640]; + unlink(src_path); + unlink(vendor_path); + snprintf(dir, sizeof(dir), "%s/src", tmp); + rmdir(dir); + snprintf(dir, sizeof(dir), "%s/vendor", tmp); + rmdir(dir); + rmdir(tmp); +} + +/* PR #756 (distilled): path_filter must retain matching files and exclude + * non-matching files regardless of where filtering occurs. Exact anchored + * paths may narrow the traversal before grep; general regular expressions stay + * in collect_grep_matches so fresh or untracked files absent from the graph + * remain discoverable. This test guards the common result invariant. */ +TEST(search_code_path_filter_prefilter_keeps_matches) { + char tmp[512], src_path[768], vendor_path[768]; + cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), + vendor_path, sizeof(vendor_path)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," + "\"path_filter\":\"^src/\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + /* The in-filter hit is returned; the out-of-filter file is not. */ + ASSERT_NOT_NULL(strstr(inner, "src/handler.go")); + ASSERT_TRUE(strstr(inner, "vendor/other.go") == NULL); + + /* Exactly the one in-filter grep match survives. */ + int grep_matches = -1; + const char *g = strstr(inner, "\"total_grep_matches\":"); + if (g) { + sscanf(g, "\"total_grep_matches\":%d", &grep_matches); + } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { + /* TOON scalar form — the search_code compact default. */ + sscanf(g, "total_grep_matches: %d", &grep_matches); + } + ASSERT_EQ(grep_matches, 1); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_prefilter_dir(tmp, src_path, vendor_path); + PASS(); +} + +/* PR #756 (distilled): a path_filter matching no files must return a clean + * zero-result response, not a subprocess or protocol error. This remains true + * for exact-path traversal narrowing and for general-regex post-filtering. */ +TEST(search_code_path_filter_matches_nothing) { + char tmp[512], src_path[768], vendor_path[768]; + cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), + vendor_path, sizeof(vendor_path)); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," + "\"path_filter\":\"^no_such_dir/\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + + int grep_matches = -1; + const char *g = strstr(inner, "\"total_grep_matches\":"); + if (g) { + sscanf(g, "\"total_grep_matches\":%d", &grep_matches); + } else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) { + /* TOON scalar form — the search_code compact default. */ + sscanf(g, "total_grep_matches: %d", &grep_matches); + } + ASSERT_EQ(grep_matches, 0); + int results = -1; + const char *r = strstr(inner, "\"total_results\":"); + if (r) { + sscanf(r, "\"total_results\":%d", &results); + } else if ((r = strstr(inner, "total_results: ")) != NULL) { + sscanf(r, "total_results: %d", &results); + } + ASSERT_EQ(results, 0); + ASSERT_TRUE(strstr(inner, "handler.go") == NULL); + ASSERT_TRUE(strstr(inner, "other.go") == NULL); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + cleanup_prefilter_dir(tmp, src_path, vendor_path); + PASS(); +} + +/* issue #283: search_code with regex=true and a syntactically invalid pattern + * must return an explicit error, not an empty result indistinguishable from a + * legitimate no-match. */ +TEST(search_code_invalid_regex_errors_issue283) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Unclosed group under regex=true → must be flagged as an error. */ + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"func(\",\"regex\":true," + "\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(resp, "invalid regex")); + free(resp); + + /* Same pattern as a literal (regex=false) must NOT error. */ + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"func(\",\"regex\":false," + "\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "invalid regex") == NULL); + free(resp); + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* issue #282: a literal '|' under regex=false is a silent 0-match trap. It must + * now be surfaced as a warning (and the result carries elapsed_ms). */ +TEST(search_code_literal_pipe_warns_issue282) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest|Nope\"," + "\"regex\":false,\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "warning")); /* surfaced, not silent */ + ASSERT_NOT_NULL(strstr(resp, "regex=true")); /* the hint names the fix */ + ASSERT_NOT_NULL(strstr(resp, "elapsed_ms")); /* timing is reported */ + free(resp); + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* issue #272: '&' in a path / file_pattern is neutralised by the command's + * quoting and must no longer be rejected as "invalid characters". */ +TEST(search_code_ampersand_accepted_issue272) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\"," + "\"file_pattern\":\"*R&D*.go\",\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "invalid characters") == NULL); + free(resp); + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_code_exact_path_filter_scopes_traversal) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"HandleRequest\"," + "\"path_filter\":\"^main\\\\.go$\"," + "\"project\":\"test-project\",\"format\":\"json\"}}}"); + char *inner = resp ? extract_text_content(resp) : NULL; + bool scope_exact = inner && strstr(inner, "\"search_scope\":\"path_filter_exact\""); + bool match_reported = inner && strstr(inner, "HandleRequest"); + bool no_error = inner && !strstr(inner, "\"isError\":true"); + + free(inner); + free(resp); + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + ASSERT_TRUE(scope_exact); + ASSERT_TRUE(match_reported); + ASSERT_TRUE(no_error); + PASS(); +} + +TEST(search_code_git_worktree_scope_includes_untracked_source) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char proj_dir[512]; + snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp); + if (!mcp_test_init_committed_repo(proj_dir, "main.go")) { + cbm_mcp_server_free(srv); + th_rmtree(tmp); + SKIP_PLATFORM("git is unavailable"); + } + + char extra_path[512]; + snprintf(extra_path, sizeof(extra_path), "%s/active_edit.go", proj_dir); + ASSERT_EQ(th_write_file(extra_path, "package main\nfunc UntrackedNeedle() {}\n"), 0); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"UntrackedNeedle\"," + "\"project\":\"test-project\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_scope\":\"git_worktree\"")); + ASSERT_NOT_NULL(strstr(inner, "UntrackedNeedle")); + ASSERT_NULL(strstr(inner, "\"isError\":true")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(tmp); + PASS(); +} + +TEST(search_code_file_pattern_uses_indexed_scope_when_available) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char vendor_dir[512]; + int n = snprintf(vendor_dir, sizeof(vendor_dir), "%s/project/vendor/generated", tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(vendor_dir)); + ASSERT_EQ(th_mkdir_p(vendor_dir), 0); + + char generated_path[512]; + n = snprintf(generated_path, sizeof(generated_path), "%s/ignored.go", vendor_dir); + ASSERT(n >= 0 && (size_t)n < sizeof(generated_path)); + ASSERT_EQ(th_write_file(generated_path, "package generated\nfunc VendoredNeedle() {}\n"), 0); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"VendoredNeedle\"," + "\"file_pattern\":\"*.go\"," + "\"project\":\"test-project\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"search_scope\":\"indexed_files\"")); + ASSERT_NULL(strstr(inner, "VendoredNeedle")); + ASSERT_NULL(strstr(inner, "\"isError\":true")); + + free(inner); + free(resp); + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_detect_changes_no_project) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":35,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"detect_changes\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "missing required argument: project")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_manage_adr_no_project) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":36,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\"," + "\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "missing required argument: project")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* Regression test for use-after-free in handle_manage_adr (get path). + * MUST FAIL before fix: free(buf) is called before yy_doc_to_str serializes doc, + * so result field is missing or contains garbage. MUST PASS after fix. */ +TEST(tool_manage_adr_get_with_existing_adr) { + /* Create a temp directory with .codebase-memory/adr.md */ + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); /* skip if mkdtemp fails */ + } + + char adr_dir[512]; + snprintf(adr_dir, sizeof(adr_dir), "%s/.codebase-memory", tmp_dir); + cbm_mkdir(adr_dir); + + char adr_path[512]; + snprintf(adr_path, sizeof(adr_path), "%s/adr.md", adr_dir); + FILE *fp = fopen(adr_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("## PURPOSE\nTest ADR content for regression test.\n\n" + "## STACK\nC, SQLite.\n\n" + "## ARCHITECTURE\nMCP server.\n", + fp); + fclose(fp); + + /* Create server and register the project */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + cbm_store_upsert_project(st, "test-adr-uaf", tmp_dir); + cbm_mcp_server_set_project(srv, "test-adr-uaf"); + + /* Call manage_adr via full JSON-RPC path to exercise cbm_jsonrpc_format_response. + * The bug: free(buf) before yy_doc_to_str causes garbage JSON; format_response + * then fails to parse the result and omits the "result" field entirely. */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\"," + "\"arguments\":{\"project\":\"test-adr-uaf\",\"mode\":\"get\"}}}"); + ASSERT_NOT_NULL(resp); + /* JSON-RPC response must include a "result" field (absent when use-after-free) */ + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + /* ADR content must appear in response */ + ASSERT_NOT_NULL(strstr(resp, "PURPOSE")); + /* Must not be an error */ + ASSERT_NULL(strstr(resp, "\"isError\":true")); + free(resp); + + /* Clean up */ + cbm_mcp_server_free(srv); + cbm_unlink(adr_path); + cbm_rmdir(adr_dir); + cbm_rmdir(tmp_dir); + PASS(); +} + +/* issue #256: manage_adr (MCP) and the UI /api/adr endpoints must share ONE + * backend. A manage_adr(update) write must be readable via cbm_store_adr_get + * (the exact API the UI's /api/adr GET uses). */ +TEST(tool_manage_adr_unified_backend_issue256) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + cbm_store_upsert_project(st, "adr-unify", "/tmp/adr-unify"); + cbm_mcp_server_set_project(srv, "adr-unify"); + + /* Write via the MCP tool. */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":120,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," + "\"mode\":\"update\",\"content\":\"## PURPOSE\\nUnified ADR backend.\\n\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + /* Read DIRECTLY via the store API the UI /api/adr uses — must see it. */ + cbm_adr_t adr; + memset(&adr, 0, sizeof(adr)); + ASSERT_EQ(cbm_store_adr_get(st, "adr-unify", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_NOT_NULL(strstr(adr.content, "Unified ADR backend.")); + cbm_store_adr_free(&adr); + + /* And manage_adr(get) round-trips the same content. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":121,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," + "\"mode\":\"get\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend.")); + ASSERT_NULL(strstr(resp, "\"isError\":true")); + free(resp); + + /* ADR presence metadata must read the same canonical SQLite backend. + * format=json: this test pins the legacy JSON "adr_present" shape; + * default_response_format is toon. */ + resp = cbm_mcp_handle_tool(srv, "get_graph_schema", + "{\"project\":\"adr-unify\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\\\"adr_present\\\":true")); + ASSERT_NULL(strstr(resp, "adr_hint")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + + +TEST(tool_index_repository_reports_store_backed_adr) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-adr-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Store-backed ADR metadata.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); + ASSERT_NULL(strstr(resp, "adr_hint")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Store-backed ADR metadata.")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +/* #1211: list_projects only ever advertises the project NAME, never the + * repo_path, but re-indexing by that same name (the natural next call) used + * to fall straight to "repo_path is required" because nothing resolved the + * name back to its stored root_path. Index once by repo_path, then re-index + * by project name alone and confirm it actually indexes instead of erroring. */ +TEST(tool_index_repository_resolves_root_path_from_project_name_issue1211) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-byname-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-byname-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char index_args[1024]; + snprintf(index_args, sizeof(index_args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", index_args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + char by_name_args[512]; + snprintf(by_name_args, sizeof(by_name_args), "{\"project\":\"%s\",\"mode\":\"fast\"}", project); + resp = cbm_mcp_handle_tool(srv, "index_repository", by_name_args); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "repo_path is required")); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +/* Same gap, opposite outcome: a project name that was never indexed has no + * stored root_path to resolve, so it must still fail with the same clear + * "repo_path is required" error rather than a resolver crash or silent + * no-op. Guards the fallback path the fix above added. */ +TEST(tool_index_repository_unknown_project_name_still_requires_repo_path) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-byname-unknown-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool(srv, "index_repository", + "{\"project\":\"never-indexed-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "repo_path is required")); + free(resp); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +} + +TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-dot-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-dot-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return helper()\n\ndef helper():\n return 1\n", fp); + fclose(fp); + + char old_cwd[CBM_SZ_4K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + ASSERT_EQ(cbm_chdir(tmp_dir), 0); + char *resp = + cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + ASSERT_NOT_NULL(resp); + if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); + } + ASSERT_NOT_NULL(strstr(resp, project)); + ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Dot-path ADR marker.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + ASSERT_EQ(cbm_chdir(tmp_dir), 0); + resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT_NOT_NULL(strstr(resp, project)); + ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); + ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\"")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Dot-path ADR marker.")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +TEST(tool_manage_adr_not_found_rich_error) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-missing-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"cbm-no-such-project-zzz\",\"mode\":\"get\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "or not indexed")); + ASSERT_NOT_NULL(strstr(resp, "hint")); + free(resp); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +} + +TEST(tool_manage_adr_get_accepts_abs_path) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-abspath-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-abspath-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Abs-path normalization test.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", tmp_dir); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Abs-path normalization test.")); + ASSERT_NULL(strstr(resp, "or not indexed")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +TEST(tool_manage_adr_get_accepts_symlink_path) { +#ifdef _WIN32 + PASS(); +#else + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-realpath-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-realpath-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + char link_path[320]; + snprintf(link_path, sizeof(link_path), "%s-link", tmp_dir); + (void)unlink(link_path); + if (symlink(tmp_dir, link_path) != 0) { + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", link_path); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT_NOT_NULL(strstr(resp, project)); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Symlink-path normalization test.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", link_path); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Symlink-path normalization test.")); + ASSERT_NULL(strstr(resp, "or not indexed")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + unlink(link_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +#endif +} + +TEST(tool_detect_changes_not_found_rich_error) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-detect-missing-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = + cbm_mcp_handle_tool(srv, "detect_changes", "{\"project\":\"cbm-no-such-project-zzz\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "or not indexed")); + ASSERT_NOT_NULL(strstr(resp, "hint")); + free(resp); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +} + + +TEST(tool_ingest_traces_basic) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":37,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"ingest_traces\"," + "\"arguments\":{\"traces\":[{\"caller\":\"a\",\"callee\":\"b\"}]}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "accepted")); + ASSERT_NOT_NULL(strstr(resp, "traces_received")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_ingest_traces_empty) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":38,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"ingest_traces\"," + "\"arguments\":{\"traces\":[]}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "accepted")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(mcp_overlay_compaction_worker_uses_own_store_and_joins) { + enum { COMPACT_ONE_GENERATION = 1 }; + const char *project = "overlay-worker-project"; + char *cache_tmp = th_mktempdir("cbm_mcp_overlay_worker_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + int n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_create_overlay_compaction_fixture(cache, project, db_path, + sizeof(db_path)), + CBM_STORE_OK); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction(srv, project, + COMPACT_ONE_GENERATION)); + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(srv, project, + COMPACT_ONE_GENERATION)); + int compacted = -1; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), CBM_STORE_OK); + ASSERT_EQ(compacted, 1); + ASSERT_FALSE(cbm_mcp_server_overlay_compaction_active(srv)); + + cbm_store_t *store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, + "overlay-worker-project.main.Old"), + 0); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, + "overlay-worker-project.helper.Helper"), + 1); + cbm_store_close(store); + + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction( + srv, project, CBM_STORE_COMPACT_ALL_GENERATIONS)); + compacted = -1; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), CBM_STORE_OK); + ASSERT_EQ(compacted, 1); + + store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, + "overlay-worker-project.helper.Helper"), + 0); + cbm_store_close(store); + + cbm_mcp_server_free(srv); + mcp_unlink_db_sidecars(db_path); + mcp_restore_cache_dir(saved_copy); + th_cleanup(cache); + PASS(); +} + +TEST(mcp_overlay_compaction_worker_reaps_finished_before_next_start) { + enum { + COMPACT_ONE_GENERATION = 1, + WAIT_ATTEMPTS = CBM_SZ_1K, + WAIT_SLEEP_US = (int)(CBM_USEC_PER_SEC / CBM_MSEC_PER_SEC), + }; + const char *project = "overlay-reap-project"; + char *cache_tmp = th_mktempdir("cbm_mcp_overlay_reap_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + int n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_create_overlay_compaction_fixture(cache, project, db_path, + sizeof(db_path)), + CBM_STORE_OK); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction(srv, project, + COMPACT_ONE_GENERATION)); + for (int attempt = 0; attempt < WAIT_ATTEMPTS; attempt++) { + if (!cbm_mcp_server_overlay_compaction_active(srv)) { + break; + } + cbm_usleep(WAIT_SLEEP_US); + } + ASSERT_FALSE(cbm_mcp_server_overlay_compaction_active(srv)); + + char req[CBM_SZ_4K]; + n = snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"%s\"}}}", + project); + ASSERT(n >= 0 && (size_t)n < sizeof(req)); + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"overlay_compaction\"")); + ASSERT_NOT_NULL(strstr(inner, "\"state\":\"finished\"")); + ASSERT_NOT_NULL(strstr(inner, "\"result_rc\":0")); + ASSERT_NOT_NULL(strstr(inner, "\"compacted_generations\":1")); + free(inner); + free(resp); + + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction( + srv, project, CBM_STORE_COMPACT_ALL_GENERATIONS)); + int compacted = -1; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), CBM_STORE_OK); + ASSERT_EQ(compacted, 1); + + cbm_store_t *store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, "overlay-reap-project.main.Old"), + 0); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, + "overlay-reap-project.helper.Helper"), + 0); + cbm_store_close(store); + + cbm_mcp_server_free(srv); + mcp_unlink_db_sidecars(db_path); + mcp_restore_cache_dir(saved_copy); + th_cleanup(cache); + PASS(); +} + +TEST(mcp_overlay_compaction_worker_missing_db_does_not_create_store) { + const char *project = "overlay-missing-project"; + char *cache_tmp = th_mktempdir("cbm_mcp_overlay_missing_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + int n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(db_path, sizeof(db_path), cache, project), + CBM_STORE_OK); + ASSERT_FALSE(cbm_file_exists(db_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction( + srv, project, CBM_STORE_COMPACT_ALL_GENERATIONS)); + + int compacted = -1; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(compacted, 0); + ASSERT_FALSE(cbm_file_exists(db_path)); + + cbm_mcp_server_free(srv); + mcp_restore_cache_dir(saved_copy); + th_cleanup(cache); + PASS(); +} + +TEST(mcp_overlay_compaction_worker_rejects_invalid_inputs) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char overlong_project[CBM_SZ_512]; + memset(overlong_project, 'a', sizeof(overlong_project) - 1); + overlong_project[sizeof(overlong_project) - 1] = '\0'; + + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(NULL, "project", 1)); + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(srv, NULL, 1)); + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(srv, "", 1)); + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(srv, "bad/project", 1)); + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(srv, overlong_project, 1)); + ASSERT_FALSE(cbm_mcp_server_start_overlay_compaction(srv, "project", -1)); + + int compacted = -1; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), CBM_STORE_OK); + ASSERT_EQ(compacted, 0); + ASSERT_FALSE(cbm_mcp_server_overlay_compaction_active(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(mcp_overlay_compaction_worker_free_joins_pending_worker) { + const char *project = "overlay-free-join-project"; + char *cache_tmp = th_mktempdir("cbm_mcp_overlay_free_join_cache"); + ASSERT_NOT_NULL(cache_tmp); + char cache[CBM_PATH_MAX]; + int n = snprintf(cache, sizeof(cache), "%s", cache_tmp); + ASSERT(n >= 0 && (size_t)n < sizeof(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_create_overlay_compaction_fixture(cache, project, db_path, + sizeof(db_path)), + CBM_STORE_OK); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction( + srv, project, CBM_STORE_COMPACT_ALL_GENERATIONS)); + cbm_mcp_server_free(srv); + + cbm_store_t *store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, + "overlay-free-join-project.main.Old"), + 0); + ASSERT_EQ(mcp_store_node_qn_exists(store, project, + "overlay-free-join-project.helper.Helper"), + 0); + cbm_store_close(store); + + mcp_unlink_db_sidecars(db_path); + mcp_restore_cache_dir(saved_copy); + th_cleanup(cache); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * IDLE STORE EVICTION + * ══════════════════════════════════════════════════════════════════ */ + +TEST(store_idle_eviction) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_mcp_server_set_project(srv, "test-evict"); + + /* Trigger resolve_store via a tool call to set store_last_used */ + char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); + free(resp); + + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + /* Evict with 0s timeout → should evict immediately */ + cbm_mcp_server_evict_idle(srv, 0); + ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(store_idle_no_eviction_within_timeout) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_mcp_server_set_project(srv, "test-evict"); + + char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); + free(resp); + + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + /* Evict with large timeout → should NOT evict */ + cbm_mcp_server_evict_idle(srv, 99999); + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(store_idle_evict_protects_initial_store) { + /* Evicting with NULL server should not crash */ + cbm_mcp_server_evict_idle(NULL, 0); + + /* Evicting server whose store was never accessed via a named project + * should NOT evict the initial in-memory store (store_last_used == 0). */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + cbm_mcp_server_evict_idle(srv, 0); + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(store_idle_evict_access_resets_timer) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_mcp_server_set_project(srv, "test-evict"); + + /* First access */ + char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); + free(resp); + + /* Second access (resets timer) */ + resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); + free(resp); + + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + /* With large timeout, store should survive */ + cbm_mcp_server_evict_idle(srv, 99999); + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + /* With 0 timeout, store should be evicted */ + cbm_mcp_server_evict_idle(srv, 0); + ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * URI HELPERS + * ══════════════════════════════════════════════════════════════════ */ + +TEST(parse_file_uri_unix) { + char path[256]; + ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/project", path, sizeof(path))); + ASSERT_STR_EQ(path, "/home/user/project"); + + ASSERT_TRUE(cbm_parse_file_uri("file:///tmp/test", path, sizeof(path))); + ASSERT_STR_EQ(path, "/tmp/test"); + + ASSERT_TRUE(cbm_parse_file_uri("file:///", path, sizeof(path))); + ASSERT_STR_EQ(path, "/"); + PASS(); +} + +TEST(parse_file_uri_windows) { + char path[256]; + /* Windows drive letter — leading / stripped */ + ASSERT_TRUE(cbm_parse_file_uri("file:///C:/Users/project", path, sizeof(path))); + ASSERT_STR_EQ(path, "C:/Users/project"); + + ASSERT_TRUE(cbm_parse_file_uri("file:///D:/Projects/myapp", path, sizeof(path))); + ASSERT_STR_EQ(path, "D:/Projects/myapp"); + PASS(); +} + +TEST(parse_file_uri_invalid) { + char path[256]; + /* Non-file URI */ + ASSERT_FALSE(cbm_parse_file_uri("https://example.com", path, sizeof(path))); + ASSERT_STR_EQ(path, ""); + + /* Empty string */ + ASSERT_FALSE(cbm_parse_file_uri("", path, sizeof(path))); + ASSERT_STR_EQ(path, ""); + + /* NULL */ + ASSERT_FALSE(cbm_parse_file_uri(NULL, path, sizeof(path))); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * SNIPPET TESTS — Port of internal/tools/snippet_test.go + * ══════════════════════════════════════════════════════════════════ */ + +#include +#include +#include + +/* Create an MCP server pre-populated with nodes/edges matching Go testSnippetServer. + * Writes a source file to tmp_dir/project/main.go. + * Caller must free the server with cbm_mcp_server_free and + * unlink the source file + rmdir manually. */ +static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz) { + /* Create temp dir */ + snprintf(tmp_dir, tmp_sz, "/tmp/cbm_snippet_test_XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) + return NULL; + + char proj_dir[512]; + snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp_dir); + cbm_mkdir(proj_dir); + + /* Write sample source file */ + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir); + FILE *fp = fopen(src_path, "w"); + if (!fp) + return NULL; + fprintf(fp, "package main\n" + "\n" + "func HandleRequest() error {\n" + "\treturn nil\n" + "}\n" + "\n" + "func ProcessOrder(id int) {\n" + "\t// process\n" + "}\n" + "\n" + "func Run() {\n" + "\t// server\n" + "}\n"); + fclose(fp); + + /* Create server with in-memory store */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) + return NULL; + + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + cbm_mcp_server_free(srv); + return NULL; + } + + const char *proj_name = "test-project"; + cbm_mcp_server_set_project(srv, proj_name); + cbm_store_upsert_project(st, proj_name, proj_dir); + + /* Create nodes */ + cbm_node_t n_hr = {0}; + n_hr.project = proj_name; + n_hr.label = "Function"; + n_hr.name = "HandleRequest"; + n_hr.qualified_name = "test-project.cmd.server.main.HandleRequest"; + n_hr.file_path = "main.go"; + n_hr.start_line = 3; + n_hr.end_line = 5; + n_hr.properties_json = "{\"signature\":\"func HandleRequest() error\"," + "\"return_type\":\"error\"," + "\"is_exported\":true," + "\"source\":\"infra\"}"; + int64_t id_hr = cbm_store_upsert_node(st, &n_hr); + + cbm_node_t n_po = {0}; + n_po.project = proj_name; + n_po.label = "Function"; + n_po.name = "ProcessOrder"; + n_po.qualified_name = "test-project.cmd.server.main.ProcessOrder"; + n_po.file_path = "main.go"; + n_po.start_line = 7; + n_po.end_line = 9; + n_po.properties_json = "{\"signature\":\"func ProcessOrder(id int)\"}"; + int64_t id_po = cbm_store_upsert_node(st, &n_po); + + cbm_node_t n_run1 = {0}; + n_run1.project = proj_name; + n_run1.label = "Function"; + n_run1.name = "Run"; + n_run1.qualified_name = "test-project.cmd.server.Run"; + n_run1.file_path = "main.go"; + n_run1.start_line = 11; + n_run1.end_line = 13; + int64_t id_run1 = cbm_store_upsert_node(st, &n_run1); + + cbm_node_t n_run2 = {0}; + n_run2.project = proj_name; + n_run2.label = "Function"; + n_run2.name = "Run"; + n_run2.qualified_name = "test-project.cmd.worker.Run"; + n_run2.file_path = "main.go"; + n_run2.start_line = 11; + n_run2.end_line = 13; + cbm_store_upsert_node(st, &n_run2); + + /* Create edges: HandleRequest -> ProcessOrder, HandleRequest -> Run1 */ + cbm_edge_t e1 = {.project = proj_name, .source_id = id_hr, .target_id = id_po, .type = "CALLS"}; + cbm_store_insert_edge(st, &e1); + + cbm_edge_t e2 = { + .project = proj_name, .source_id = id_hr, .target_id = id_run1, .type = "CALLS"}; + cbm_store_insert_edge(st, &e2); + (void)id_run1; /* run1 used for edge above */ + + return srv; +} + +/* Cleanup temp files created by setup_snippet_server */ +static void cleanup_snippet_dir(const char *tmp_dir) { + char path[512]; + snprintf(path, sizeof(path), "%s/project/main.go", tmp_dir); + cbm_unlink(path); + snprintf(path, sizeof(path), "%s/project", tmp_dir); + cbm_rmdir(path); + cbm_rmdir(tmp_dir); +} + +/* Extract the inner "text" value from an MCP tool result JSON. + * The MCP envelope is: {"content":[{"type":"text","text":""}]} + * This returns the unescaped inner JSON. Caller must free. */ +static char *extract_text_content(const char *mcp_result) { + if (!mcp_result) + return NULL; + yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0); + if (!doc) + return strdup(mcp_result); /* fallback */ + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *content = yyjson_obj_get(root, "content"); + if (!content) { + /* Handle JSON-RPC wrapper: {"jsonrpc":...,"result":{"content":[...]}} */ + yyjson_val *rpc_result = yyjson_obj_get(root, "result"); + if (rpc_result) { + content = yyjson_obj_get(rpc_result, "content"); + } + } + if (!content || !yyjson_is_arr(content)) { + yyjson_doc_free(doc); + return strdup(mcp_result); + } + yyjson_val *item = yyjson_arr_get(content, 0); + if (!item) { + yyjson_doc_free(doc); + return strdup(mcp_result); + } + yyjson_val *text = yyjson_obj_get(item, "text"); + const char *str = yyjson_get_str(text); + char *result = str ? strdup(str) : strdup(mcp_result); + yyjson_doc_free(doc); + return result; +} + +/* Call get_code_snippet and extract inner text content. + * Caller must free returned string. */ +static char *call_snippet(cbm_mcp_server_t *srv, const char *args_json) { + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", args_json); + char *text = extract_text_content(raw); + free(raw); + return text; +} + +static bool is_valid_json_response(const char *json) { + if (!json) { + return false; + } + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) { + return false; + } + yyjson_doc_free(doc); + return true; +} + +static int count_substr_mcp(const char *s, const char *needle) { + int count = 0; + if (!s || !needle) return 0; + size_t nlen = strlen(needle); + if (nlen == 0) return 0; + while ((s = strstr(s, needle)) != NULL) { + count++; + s += nlen; + } + return count; +} + +static bool snippet_source_has_replacement(const char *json) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) { + return false; + } + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *source = yyjson_obj_get(root, "source"); + const char *source_str = yyjson_get_str(source); + bool found = source_str && strstr(source_str, "\xEF\xBF\xBD"); + yyjson_doc_free(doc); + return found; +} + +/* ── TestSnippet_ExactQN ──────────────────────────────────────── */ + +TEST(snippet_exact_qn) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "\"source\"")); + /* Exact match should NOT have match_method */ + ASSERT_NULL(strstr(resp, "\"match_method\"")); + /* No property-blob spill: the source IS the payload (signature and + * docstring are literally in it); metrics live behind search_graph + * fields=[...]. */ + ASSERT_NULL(strstr(resp, "\"signature\"")); + ASSERT_NULL(strstr(resp, "\"return_type\"")); + /* Caller/callee counts: 0 callers, 2 callees */ + ASSERT_NOT_NULL(strstr(resp, "\"callers\":0")); + ASSERT_NOT_NULL(strstr(resp, "\"callees\":2")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(snippet_source_key_is_code_body_only) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\",\"compact\":false}"); + ASSERT_NOT_NULL(resp); + ASSERT_EQ(count_substr_mcp(resp, "\"source\":"), 1); + ASSERT_NOT_NULL(strstr(resp, "\"source_origin\":\"project\"")); + ASSERT_NOT_NULL(strstr(resp, "\"property_source\":\"infra\"")); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + const char *source = yyjson_get_str(yyjson_obj_get(root, "source")); + ASSERT_NOT_NULL(source); + ASSERT_NOT_NULL(strstr(source, "func HandleRequest() error")); + yyjson_doc_free(doc); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(snippet_signature_mode_retains_property_metadata) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\",\"mode\":\"signature\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"signature\"")); + ASSERT_NULL(strstr(resp, "\"source\"")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +TEST(snippet_invalid_mode_errors) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\",\"mode\":\"compact\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\":\"invalid mode 'compact'\"")); + ASSERT_NOT_NULL(strstr(resp, "Valid values: full, signature, head_tail")); + ASSERT_NULL(strstr(resp, "func HandleRequest() error")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_CompactFalse: name present when compact=false ── */ + +TEST(snippet_compact_false_name_present) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* compact=false: name must be present even when it equals last segment of QN */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\"," + "\"compact\":false}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_QNSuffix ─────────────────────────────────────── */ + +TEST(snippet_qn_suffix) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = call_snippet(srv, "{\"qualified_name\":\"main.HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); /* present in qualified_name */ + ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); + ASSERT_NOT_NULL(strstr(resp, "\"source\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_UniqueShortName ──────────────────────────────── */ + +TEST(snippet_unique_short_name) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "ProcessOrder" is unique — suffix tier matches (QN ends with .ProcessOrder) */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"ProcessOrder\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"ProcessOrder\"")); + ASSERT_NOT_NULL(strstr(resp, "ProcessOrder")); /* present in qualified_name */ + ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); + ASSERT_NOT_NULL(strstr(resp, "\"source\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_NameTier ─────────────────────────────────────── */ + +TEST(snippet_name_tier) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "HandleRequest" — suffix tier finds it (QN ends with .HandleRequest) */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); /* present in qualified_name */ + ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_AmbiguousShortName ───────────────────────────── */ + +TEST(snippet_ambiguous_short_name) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "Run" matches 2 nodes — should return suggestions */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\"")); + ASSERT_NOT_NULL(strstr(resp, "\"message\"")); + ASSERT_NOT_NULL(strstr(resp, "\"suggestions\"")); + /* Must NOT have "error" key */ + ASSERT_NULL(strstr(resp, "\"error\"")); + /* Must NOT have "source" */ + ASSERT_NULL(strstr(resp, "\"source\"")); + /* Should have at least 2 suggestions with qualified_name */ + ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.server.Run")); + ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.worker.Run")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_NotFound ─────────────────────────────────────── */ + +TEST(snippet_not_found) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = call_snippet(srv, "{\"qualified_name\":\"CompletelyNonexistentFunctionXYZ123\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* Should return error or suggestions */ + ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "suggestions")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_FuzzySuggestions ─────────────────────────────── */ + +TEST(snippet_fuzzy_suggestions) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "Handle" is not an exact QN or suffix — should get not-found guidance */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"Handle\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* Should guide user to search_graph */ + ASSERT_NOT_NULL(strstr(resp, "search_graph")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_EnrichedProperties ───────────────────────────── */ + +TEST(snippet_enriched_properties) { + /* GUARD (inverted since the compact-output change): the snippet response + * carries the verbatim source plus location/degree/coverage metadata and + * NOTHING from the node's property blob — no signature/return_type/ + * is_exported duplication, and never the fp/sp/bt similarity internals + * (41% of the legacy response). */ + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"source\"")); + ASSERT_NULL(strstr(resp, "\"signature\"")); + ASSERT_NULL(strstr(resp, "\"return_type\"")); + ASSERT_NULL(strstr(resp, "\"is_exported\"")); + ASSERT_NULL(strstr(resp, "\"fp\"")); + ASSERT_NULL(strstr(resp, "\"bt\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_FuzzyLastSegment ─────────────────────────────── */ + +TEST(snippet_fuzzy_last_segment) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "auth.handlers.HandleRequest" — suffix match should find HandleRequest */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"auth.handlers.HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* Should either find it via suffix or guide to search_graph */ + ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL || strstr(resp, "search_graph") != NULL); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_AutoResolve_Default ──────────────────────────── */ + +TEST(snippet_auto_resolve_default) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "Run" is ambiguous (2 candidates). Without auto_resolve → suggestions */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\"")); + ASSERT_NULL(strstr(resp, "\"source\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_AutoResolve_Enabled ──────────────────────────── */ + +TEST(snippet_auto_resolve_enabled) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* "Run" — suffix match should find candidates or guide to search */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* "Run" matches multiple nodes via suffix → should get suggestions or source */ + ASSERT_TRUE(strstr(resp, "Run") != NULL); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_IncludeNeighbors_Default ─────────────────────── */ + +TEST(snippet_include_neighbors_default) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + /* Without include_neighbors → NO caller_names/callee_names */ + ASSERT_NULL(strstr(resp, "\"caller_names\"")); + ASSERT_NULL(strstr(resp, "\"callee_names\"")); + /* But should still have counts */ + ASSERT_NOT_NULL(strstr(resp, "\"callers\"")); + ASSERT_NOT_NULL(strstr(resp, "\"callees\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_IncludeNeighbors_Enabled ─────────────────────── */ + +TEST(snippet_include_neighbors_enabled) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *resp = + call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"include_neighbors\":true,\"project\":\"test-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"source\"")); + /* HandleRequest has 0 callers → no caller_names array */ + ASSERT_NULL(strstr(resp, "\"caller_names\"")); + /* HandleRequest has 2 callees: ProcessOrder and Run */ + ASSERT_NOT_NULL(strstr(resp, "\"callee_names\"")); + ASSERT_NOT_NULL(strstr(resp, "ProcessOrder")); + ASSERT_NOT_NULL(strstr(resp, "Run")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── TestSnippet_SourceInvalidUtf8 ────────────────────────────── */ + +TEST(snippet_source_invalid_utf8) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp); + FILE *fp = fopen(src_path, "wb"); + ASSERT_NOT_NULL(fp); + const unsigned char source[] = { + 'p', 'a', 'c', 'k', 'a', 'g', 'e', ' ', 'm', 'a', 'i', 'n', '\n', '\n', + 'f', 'u', 'n', 'c', ' ', 'H', 'a', 'n', 'd', 'l', 'e', 'R', 'e', 'q', + 'u', 'e', 's', 't', '(', ')', ' ', 'e', 'r', 'r', 'o', 'r', ' ', '{', + '\n', '\t', '/', '/', ' ', 0xC0, 0xD4, 0xB7, 0xC2, '\n', '\t', 'r', 'e', 't', + 'u', 'r', 'n', ' ', 'n', 'i', 'l', '\n', '}', '\n'}; + ASSERT_EQ(fwrite(source, 1, sizeof(source), fp), sizeof(source)); + ASSERT_EQ(fclose(fp), 0); + + char *raw = + cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\"}"); + ASSERT_TRUE(is_valid_json_response(raw)); + char *resp = extract_text_content(raw); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(is_valid_json_response(resp)); + ASSERT_NULL(strstr(resp, "\xC0\xD4")); + ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); + ASSERT_NOT_NULL(strstr(resp, "return nil")); + ASSERT_TRUE(snippet_source_has_replacement(resp)); + + free(resp); + free(raw); + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * JSON-RPC PARSING — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(jsonrpc_parse_empty_string) { + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse("", &req); + ASSERT_EQ(rc, CBM_JSONRPC_PARSE_ERROR); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_missing_jsonrpc_field) { + /* JSON-RPC 2.0 requires the version member on every request. */ + const char *line = "{\"id\":1,\"method\":\"initialize\",\"params\":{}}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, CBM_JSONRPC_INVALID_REQUEST); + ASSERT_TRUE(req.has_id); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_missing_method) { + /* method is required — should fail */ + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"params\":{}}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, CBM_JSONRPC_INVALID_REQUEST); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_rejects_wrong_version) { + const char *line = "{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"initialize\"}"; + cbm_jsonrpc_request_t req = {0}; + ASSERT_EQ(cbm_jsonrpc_parse(line, &req), CBM_JSONRPC_INVALID_REQUEST); + ASSERT_TRUE(req.has_id); + ASSERT_EQ(req.id, 1); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_string_id) { + /* JSON-RPC §4: string and numeric ids are distinct. A string id is + * preserved verbatim (issue #253), never coerced to a number. */ + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":\"99\",\"method\":\"tools/list\"}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(req.has_id); + ASSERT_NOT_NULL(req.id_str); + ASSERT_STR_EQ(req.id_str, "99"); + ASSERT_STR_EQ(req.method, "tools/list"); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_no_params) { + /* Request with no params field — params_raw should be NULL */ + const char *line = "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/list\"}"; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_NULL(req.params_raw); + ASSERT_EQ(req.id, 5); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_extra_whitespace) { + /* Leading/trailing whitespace and internal spacing in JSON */ + const char *line = " { \"jsonrpc\" : \"2.0\" , \"id\" : 7 , \"method\" : \"ping\" } "; + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse(line, &req); + ASSERT_EQ(rc, 0); + ASSERT_EQ(req.id, 7); + ASSERT_STR_EQ(req.method, "ping"); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_array_not_object) { + /* JSON array at root — not a valid JSON-RPC request */ + cbm_jsonrpc_request_t req = {0}; + int rc = cbm_jsonrpc_parse("[1,2,3]", &req); + ASSERT_EQ(rc, CBM_JSONRPC_INVALID_REQUEST); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * ARGUMENT EXTRACTION — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(mcp_get_string_arg_empty_json) { + /* Empty JSON string — yyjson_read fails → NULL */ + char *val = cbm_mcp_get_string_arg("", "key"); + ASSERT_NULL(val); + PASS(); +} + +TEST(mcp_get_string_arg_empty_object) { + /* Valid JSON with no keys → NULL for any key */ + char *val = cbm_mcp_get_string_arg("{}", "key"); + ASSERT_NULL(val); + PASS(); +} + +TEST(mcp_get_string_arg_nested_value) { + /* Value is an object, not a string → should return NULL */ + const char *args = "{\"config\":{\"nested\":true},\"name\":\"hello\"}"; + char *val = cbm_mcp_get_string_arg(args, "config"); + ASSERT_NULL(val); /* not a string type */ + val = cbm_mcp_get_string_arg(args, "name"); + ASSERT_NOT_NULL(val); + ASSERT_STR_EQ(val, "hello"); + free(val); + PASS(); +} + +TEST(mcp_get_string_arg_int_value) { + /* Value is an integer, not a string → NULL */ + char *val = cbm_mcp_get_string_arg("{\"count\":42}", "count"); + ASSERT_NULL(val); + PASS(); +} + +TEST(mcp_get_int_arg_empty_json) { + int val = cbm_mcp_get_int_arg("", "key", 99); + ASSERT_EQ(val, 99); + PASS(); +} + +TEST(mcp_get_int_arg_string_value) { + /* Value is a string, not int → should return default */ + int val = cbm_mcp_get_int_arg("{\"limit\":\"ten\"}", "limit", 5); + ASSERT_EQ(val, 5); + PASS(); +} + +TEST(mcp_get_int_arg_bool_value) { + /* Value is a bool, not int → default */ + int val = cbm_mcp_get_int_arg("{\"flag\":true}", "flag", -1); + ASSERT_EQ(val, -1); + PASS(); +} + +TEST(mcp_get_bool_arg_empty_json) { + bool val = cbm_mcp_get_bool_arg("", "key"); + ASSERT_FALSE(val); + PASS(); +} + +TEST(mcp_get_bool_arg_int_value) { + /* Value is int 1, not bool → should return false */ + bool val = cbm_mcp_get_bool_arg("{\"flag\":1}", "flag"); + ASSERT_FALSE(val); + PASS(); +} + +TEST(mcp_get_tool_name_empty_json) { + char *name = cbm_mcp_get_tool_name(""); + ASSERT_NULL(name); + PASS(); +} + +TEST(mcp_get_tool_name_missing_name) { + char *name = cbm_mcp_get_tool_name("{\"arguments\":{}}"); + ASSERT_NULL(name); + PASS(); +} + +TEST(mcp_get_arguments_empty_json) { + char *args = cbm_mcp_get_arguments(""); + ASSERT_NULL(args); + PASS(); +} + +TEST(mcp_get_arguments_no_arguments_key) { + /* No "arguments" key → returns "{}" */ + char *args = cbm_mcp_get_arguments("{\"name\":\"tool\"}"); + ASSERT_NOT_NULL(args); + ASSERT_STR_EQ(args, "{}"); + free(args); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * FILE URI PARSING — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(parse_file_uri_http_scheme) { + char path[256]; + ASSERT_FALSE(cbm_parse_file_uri("http://example.com/path", path, sizeof(path))); + ASSERT_STR_EQ(path, ""); + PASS(); +} + +TEST(parse_file_uri_ftp_scheme) { + char path[256]; + ASSERT_FALSE(cbm_parse_file_uri("ftp://server/file.txt", path, sizeof(path))); + ASSERT_STR_EQ(path, ""); + PASS(); +} + +TEST(parse_file_uri_buffer_too_small) { + char path[5]; /* only 5 bytes — path gets truncated */ + ASSERT_TRUE(cbm_parse_file_uri("file:///usr/local/bin", path, sizeof(path))); + /* snprintf truncates to 4 chars + NUL */ + ASSERT_EQ(strlen(path), 4); + ASSERT_STR_EQ(path, "/usr"); + PASS(); +} + +TEST(parse_file_uri_spaces_in_path) { + char path[256]; + ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/my%20project", path, sizeof(path))); + /* Raw percent-encoding is preserved (not decoded) */ + ASSERT_STR_EQ(path, "/home/user/my%20project"); + PASS(); +} + +TEST(parse_file_uri_null_out_path) { + /* NULL out_path — should not crash */ + ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", NULL, 256)); + PASS(); +} + +TEST(parse_file_uri_zero_size) { + char path[256] = "garbage"; + /* out_size=0 → should fail safely */ + ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", path, 0)); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * SERVER HANDLE — EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(server_handle_invalid_json) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle(srv, "this is not json at all"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "-32700")); /* Parse error */ + ASSERT_NOT_NULL(strstr(resp, "\"id\":null")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_empty_object) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + /* Valid JSON but no required JSON-RPC members → Invalid Request. */ + char *resp = cbm_mcp_server_handle(srv, "{}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32600")); + ASSERT_NOT_NULL(strstr(resp, "\"id\":null")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_invalid_request_preserves_valid_id) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":77}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32600")); + ASSERT_NOT_NULL(strstr(resp, "\"id\":77")); + ASSERT_NULL(strstr(resp, "\"result\"")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_error_preserves_string_id) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":\"resource-78\",\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://does-not-exist\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"id\":\"resource-78\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32002")); + ASSERT_NULL(strstr(resp, "\"result\"")); + free(resp); + ASSERT_FALSE(cbm_mcp_server_cancel_active(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_tools_call_missing_name) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + /* tools/call with no tool name in params */ + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," + "\"params\":{\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + /* A missing required name fails the CallToolRequest schema and therefore + * uses a JSON-RPC invalid-params error rather than a tool result. */ + ASSERT_NOT_NULL(strstr(resp, "\"id\":50")); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602")); + ASSERT_NOT_NULL(strstr(resp, "Missing tool name")); + ASSERT_NULL(strstr(resp, "\"result\"")); + ASSERT_NULL(strstr(resp, "\"isError\"")); + free(resp); + ASSERT_FALSE(cbm_mcp_server_cancel_active(srv)); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_tools_call_rejects_non_object_arguments) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":51,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":\"not-an-object\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"id\":51")); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602")); + ASSERT_NOT_NULL(strstr(resp, "Tool arguments must be an object")); + ASSERT_NULL(strstr(resp, "\"result\"")); + ASSERT_NULL(strstr(resp, "\"isError\"")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_handle_unknown_tool_preserves_string_id) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":\"call-52\",\"method\":\"tools/call\"," + "\"params\":{\"name\":\"nonexistent_tool\",\"arguments\":{}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"id\":\"call-52\"")); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602")); + ASSERT_NULL(strstr(resp, "\"result\"")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +typedef struct { + cbm_mcp_server_t *server; + atomic_int done; + char *response; +} mcp_startup_search_request_t; + +static void *mcp_startup_search_request(void *opaque) { + mcp_startup_search_request_t *request = opaque; + request->response = cbm_mcp_server_handle( + request->server, + "{\"jsonrpc\":\"2.0\",\"id\":59,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"name_pattern\":\"deferred_first_response_target\",\"format\":\"json\"}}}"); + atomic_store_explicit(&request->done, 1, memory_order_release); + return NULL; +} + +TEST(first_graph_call_reports_retryable_startup_index_without_consuming_ready_context) { + char repo[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(repo, sizeof(repo), "%s/cbm-first-retry-repo-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-first-retry-cache-XXXXXX", cbm_tmpdir()); + bool repo_created = cbm_mkdtemp(repo) != NULL; + bool cache_created = cbm_mkdtemp(cache) != NULL; + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + if (cache_created) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); + } + + char source_path[CBM_SZ_512]; + snprintf(source_path, sizeof(source_path), "%s/deferred_first.py", repo); + FILE *source = repo_created ? fopen(source_path, "w") : NULL; + if (source) { + fputs("def deferred_first_response_target():\n return 42\n", source); + fclose(source); + } + + char old_cwd[CBM_SZ_1K]; + bool cwd_saved = cbm_getcwd(old_cwd, sizeof(old_cwd)) != NULL; + bool cwd_changed = cwd_saved && repo_created && cbm_chdir(repo) == 0; + cbm_config_t *config = cache_created ? cbm_config_open(cache) : NULL; + if (config) { + (void)cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true"); + (void)cbm_config_set(config, CBM_CONFIG_AUTO_WATCH, "false"); + } + cbm_mcp_server_t *srv = config && cwd_changed ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_mcp_server_set_config(srv, config); + } + + mcp_startup_search_request_t request = { + .server = srv, + .response = NULL, + }; + atomic_init(&request.done, 0); + cbm_thread_t request_thread; + bool request_started = false; + char *initialize = NULL; + + /* Hold the existing pipeline lock so the startup worker is provably live. + * The tool request must return retry metadata while this owner retains the + * lock; the pre-fix unbounded join cannot do so. */ + cbm_pipeline_lock(); + if (srv) { + initialize = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":58,\"method\":\"initialize\",\"params\":{}}"); + request_started = + cbm_thread_create(&request_thread, 0, mcp_startup_search_request, &request) == 0; + } + uint64_t deadline = cbm_now_ms() + MCP_REQUEST_TEST_TIMEOUT_SECONDS * CBM_MSEC_PER_SEC; + while (request_started && atomic_load_explicit(&request.done, memory_order_acquire) == 0 && + cbm_now_ms() < deadline) { + cbm_usleep(CBM_USEC_PER_SEC / CBM_MSEC_PER_SEC); + } + bool returned_while_index_live = + request_started && atomic_load_explicit(&request.done, memory_order_acquire) != 0; + cbm_pipeline_unlock(); + if (request_started) { + (void)cbm_thread_join(&request_thread); + } + if (srv) { + (void)cbm_mcp_server_join_autoindex(srv); + } + + char *retry = + srv ? cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":60,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"name_pattern\":\"deferred_first_response_target\"," + "\"format\":\"json\"}}}") + : NULL; + + bool retryable = request.response && strstr(request.response, "\"isError\":true") && + response_contains_json_fragment(request.response, "\"status\":\"indexing\"") && + response_contains_json_fragment(request.response, "\"retryable\":true") && + strstr(request.response, "retry this same tool call"); + bool retry_exact_and_ready = retry && strstr(retry, "deferred_first_response_target") && + response_contains_json_fragment(retry, "\"status\":\"ready\"") && + response_contains_json_fragment(retry, "\"architecture\""); + + free(initialize); + free(request.response); + free(retry); + cbm_mcp_server_free(srv); + cbm_config_close(config); + if (cwd_changed) { + (void)cbm_chdir(old_cwd); + } + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + if (source) { + (void)cbm_unlink(source_path); + } + if (cache_created) { + th_rmtree(cache); + } + if (repo_created) { + (void)cbm_rmdir(repo); + } + + ASSERT_TRUE(repo_created); + ASSERT_TRUE(cache_created); + ASSERT_NOT_NULL(source); + ASSERT_NOT_NULL(srv); + ASSERT_NOT_NULL(initialize); + ASSERT_TRUE(returned_while_index_live); + ASSERT_TRUE(retryable); + ASSERT_TRUE(retry_exact_and_ready); + PASS(); +} + +TEST(first_graph_call_is_ready_or_retryable_until_startup_index_publishes) { + char repo[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-first-call-repo-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-first-call-cache-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char source_path[CBM_SZ_512]; + snprintf(source_path, sizeof(source_path), "%s/first_call.py", repo); + FILE *source = fopen(source_path, "w"); + ASSERT_NOT_NULL(source); + fputs("def first_response_target():\n return 42\n", source); + fclose(source); + + char old_cwd[CBM_SZ_1K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + ASSERT_EQ(cbm_chdir(repo), 0); + + cbm_config_t *config = cbm_config_open(cache); + ASSERT_NOT_NULL(config); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, config); + + /* initialize starts the background index. Depending on scheduling, the + * immediately following call either observes its publication or receives + * an actionable retry without consuming the one-shot ready context. */ + char *initialize = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":60,\"method\":\"initialize\",\"params\":{}}"); + ASSERT_NOT_NULL(initialize); + free(initialize); + + char *first_response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":61,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"name_pattern\":\"first_response_target\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(first_response); + bool first_ready = strstr(first_response, "first_response_target") && + response_contains_json_fragment(first_response, "\"status\":\"ready\""); + bool first_retryable = + response_contains_json_fragment(first_response, "\"status\":\"indexing\"") && + response_contains_json_fragment(first_response, "\"retryable\":true") && + strstr(first_response, "retry this same tool call"); + ASSERT_TRUE(first_ready || first_retryable); + + ASSERT_EQ(cbm_mcp_server_join_autoindex(srv), 0); + char *published_response = + first_ready ? cbm_strdup(first_response) + : cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"name_pattern\":\"first_response_target\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(published_response); + ASSERT_NOT_NULL(strstr(published_response, "first_response_target")); + ASSERT_TRUE(response_contains_json_fragment(published_response, "\"status\":\"ready\"")); + ASSERT_TRUE(response_contains_json_fragment(published_response, "\"architecture\"")); + free(first_response); + free(published_response); + + cbm_mcp_server_free(srv); + cbm_config_close(config); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_unlink(source_path); + th_rmtree(cache); + cbm_rmdir(repo); + PASS(); +} + +TEST(first_search_code_call_is_ready_or_retryable_until_startup_index_publishes) { + char repo[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-first-source-call-repo-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-first-source-call-cache-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char source_path[CBM_SZ_512]; + snprintf(source_path, sizeof(source_path), "%s/first_source_call.py", repo); + FILE *source = fopen(source_path, "w"); + ASSERT_NOT_NULL(source); + fputs("def first_source_response_target():\n return 42\n", source); + fclose(source); + + char old_cwd[CBM_SZ_1K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + ASSERT_EQ(cbm_chdir(repo), 0); + + cbm_config_t *config = cbm_config_open(cache); + ASSERT_NOT_NULL(config); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, config); + + char *initialize = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"initialize\",\"params\":{}}"); + ASSERT_NOT_NULL(initialize); + free(initialize); + + char *first_response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\",\"arguments\":{" + "\"pattern\":\"first_source_response_target\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(first_response); + bool first_ready = strstr(first_response, "first_source_response_target") != NULL; + bool first_retryable = + response_contains_json_fragment(first_response, "\"status\":\"indexing\"") && + response_contains_json_fragment(first_response, "\"retryable\":true") && + strstr(first_response, "retry this same tool call"); + ASSERT_TRUE(first_ready || first_retryable); + ASSERT_NULL(strstr(first_response, "project not found or not indexed")); + + ASSERT_EQ(cbm_mcp_server_join_autoindex(srv), 0); + char *published_response = + first_ready + ? cbm_strdup(first_response) + : cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":64,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\",\"arguments\":{" + "\"pattern\":\"first_source_response_target\",\"format\":\"json\"}}}"); + ASSERT_NOT_NULL(published_response); + ASSERT_NOT_NULL(strstr(published_response, "first_source_response_target")); + free(first_response); + free(published_response); + + cbm_mcp_server_free(srv); + cbm_config_close(config); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_unlink(source_path); + th_rmtree(cache); + cbm_rmdir(repo); + PASS(); +} + +static char *request_missing_index_with_mode(cbm_config_t *config, int request_id, + bool reveal_hidden_tools) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) { + return NULL; + } + cbm_mcp_server_set_config(srv, config); + char *initialize = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); + if (!initialize) { + cbm_mcp_server_free(srv); + return NULL; + } + free(initialize); + if (reveal_hidden_tools) { + char *reveal = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + if (!reveal) { + cbm_mcp_server_free(srv); + return NULL; + } + free(reveal); + } + char request[CBM_SZ_1K]; + int written = snprintf(request, sizeof(request), + "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"name_pattern\":\"blocked_index_target\"}}}", + request_id); + char *response = written > 0 && (size_t)written < sizeof(request) + ? cbm_mcp_server_handle(srv, request) + : NULL; + cbm_mcp_server_free(srv); + return response; +} + +static bool response_has_structured_content(const char *response) { + yyjson_doc *doc = response ? yyjson_read(response, strlen(response), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *result = root ? yyjson_obj_get(root, "result") : NULL; + bool found = result && yyjson_is_obj(yyjson_obj_get(result, "structuredContent")); + yyjson_doc_free(doc); + return found; +} + +static bool response_text_field_contains(const char *response, const char *field, + const char *needle) { + char *inner = extract_text_content(response); + yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *value = root ? yyjson_obj_get(root, field) : NULL; + bool found = value && yyjson_is_str(value) && strstr(yyjson_get_str(value), needle) != NULL; + yyjson_doc_free(doc); + free(inner); + return found; +} + +TEST(first_search_reports_automatic_index_block_reason) { + char repo[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-index-block-repo-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-index-block-cache-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + /* Keep one unrelated readable project in the cache. Recovery metadata + * must not disappear merely because build_project_list_error_srv() can + * also offer an indexed-project alternative. */ + char decoy_db_path[CBM_SZ_512]; + snprintf(decoy_db_path, sizeof(decoy_db_path), "%s/decoy.db", cache); + cbm_store_t *decoy_store = cbm_store_open_path(decoy_db_path); + ASSERT_NOT_NULL(decoy_store); + ASSERT_EQ(cbm_store_upsert_project(decoy_store, "decoy-indexed-project", cache), CBM_STORE_OK); + cbm_store_close(decoy_store); + + char source_path[CBM_SZ_512]; + snprintf(source_path, sizeof(source_path), "%s/blocked.py", repo); + FILE *source = fopen(source_path, "w"); + ASSERT_NOT_NULL(source); + fputs("def blocked_index_target():\n return 42\n", source); + fclose(source); + char second_source_path[CBM_SZ_512]; + snprintf(second_source_path, sizeof(second_source_path), "%s/also_blocked.py", repo); + source = fopen(second_source_path, "w"); + ASSERT_NOT_NULL(source); + fputs("def second_blocked_target():\n return 43\n", source); + fclose(source); + + char old_cwd[CBM_SZ_1K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + ASSERT_EQ(cbm_chdir(repo), 0); + + cbm_config_t *config = cbm_config_open(cache); + ASSERT_NOT_NULL(config); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "false"), 0); + + char *response = request_missing_index_with_mode(config, 65, false); + ASSERT_NOT_NULL(response); + ASSERT_TRUE(response_has_structured_content(response)); + ASSERT_TRUE(response_text_field_contains(response, "status", "not_indexed")); + ASSERT_TRUE(response_text_field_contains(response, "action_required", "auto_index=false")); + ASSERT_NOT_NULL(strstr(response, "auto_index=false")); + ASSERT_NOT_NULL(strstr(response, "_hidden_tools")); + ASSERT_NOT_NULL(strstr(response, "tools/list")); + ASSERT_NOT_NULL(strstr(response, "index_repository")); + ASSERT_NOT_NULL(strstr(response, "repo_path")); + ASSERT_NULL(strstr(response, "\"status\":\"auto_indexing\"")); + free(response); + + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "true"), 0); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX_LIMIT, "1"), 0); + response = request_missing_index_with_mode(config, 67, false); + ASSERT_NOT_NULL(response); + ASSERT_TRUE(response_has_structured_content(response)); + ASSERT_TRUE(response_text_field_contains(response, "action_required", "auto_index_limit=1")); + ASSERT_NOT_NULL(strstr(response, "auto_index_limit")); + /* The bounded counter reports the first rejected cardinality, not the + * saturated configured limit. This also proves the MCP resolve path uses + * the same configured-limit helper as daemon admission. */ + ASSERT_NOT_NULL(strstr(response, "at least 2 indexable files")); + ASSERT_NOT_NULL(strstr(response, "auto_index_limit=1")); + ASSERT_NOT_NULL(strstr(response, "_hidden_tools")); + ASSERT_NOT_NULL(strstr(response, "tools/list")); + ASSERT_NOT_NULL(strstr(response, "index_repository")); + ASSERT_NOT_NULL(strstr(response, "repo_path")); + ASSERT_NULL(strstr(response, "\"status\":\"auto_indexing\"")); + free(response); + + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_AUTO_INDEX, "false"), 0); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_CLASSIC), 0); + response = request_missing_index_with_mode(config, 69, false); + ASSERT_NOT_NULL(response); + ASSERT_TRUE(response_has_structured_content(response)); + ASSERT_TRUE(response_text_field_contains(response, "action_required", "call index_repository")); + ASSERT_NOT_NULL(strstr(response, "call index_repository")); + ASSERT_NOT_NULL(strstr(response, "repo_path")); + ASSERT_NULL(strstr(response, "_hidden_tools")); + free(response); + + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_STREAMLINED), 0); + response = request_missing_index_with_mode(config, 71, true); + ASSERT_NOT_NULL(response); + ASSERT_TRUE(response_has_structured_content(response)); + ASSERT_TRUE(response_text_field_contains(response, "action_required", "call index_repository")); + ASSERT_NOT_NULL(strstr(response, "call index_repository")); + ASSERT_NOT_NULL(strstr(response, "repo_path")); + ASSERT_NULL(strstr(response, "_hidden_tools")); + ASSERT_NULL(strstr(response, "refresh tools/list")); + free(response); + + /* An empty cache must retain the same machine-readable recovery contract. + * Previously this branch put the instruction only in a generic "hint" and + * omitted status, so automated callers had to parse prose or guess whether + * retrying could succeed. */ + cbm_remove_db_sidecars(decoy_db_path); + ASSERT_EQ(cbm_unlink(decoy_db_path), 0); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_TOOL_MODE, CBM_CONFIG_TOOL_MODE_CLASSIC), 0); + response = request_missing_index_with_mode(config, 73, false); + ASSERT_NOT_NULL(response); + ASSERT_TRUE(response_has_structured_content(response)); + ASSERT_TRUE(response_text_field_contains(response, "status", "not_indexed")); + ASSERT_TRUE(response_text_field_contains(response, "action_required", "call index_repository")); + ASSERT_NOT_NULL(strstr(response, "repo_path")); + free(response); + + cbm_config_close(config); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + th_rmtree(repo); + th_rmtree(cache); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * POLL/GETLINE FILE* BUFFERING FIX + * ══════════════════════════════════════════════════════════════════ */ + +#ifndef _WIN32 +#include +#include + +enum { MCP_STDIO_TEST_TIMEOUT_SECONDS = MCP_REQUEST_TEST_TIMEOUT_SECONDS }; + +/* Signal handler used by alarm() to abort the test if it hangs */ +static void alarm_handler(int sig) { + (void)sig; + /* Writing to stderr is async-signal-safe */ + const char msg[] = "FAIL: mcp_server_run_rapid_messages timed out (>5s)\n"; + write(STDERR_FILENO, msg, sizeof(msg) - 1); + _exit(1); +} + +static bool append_content_length_frame(char **dst, size_t *remaining, const char *json) { + if (!dst || !*dst || !remaining || !json) return false; + size_t len = strlen(json); + int n = snprintf(*dst, *remaining, "Content-Length: %zu\r\n\r\n%s", len, json); + if (n < 0 || (size_t)n >= *remaining) return false; + *dst += n; + *remaining -= (size_t)n; + return true; +} + +TEST(mcp_server_run_rapid_messages) { + /* Simulate a client sending initialize + notifications/initialized + + * tools/list all at once (no delays), which exercises the FILE* + * buffering fix: the first getline() over-reads kernel data into the + * libc buffer; without the fix, subsequent poll() calls block for 60s. + * + * We use alarm() to abort the test process if the server hangs. */ + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + /* Write all 3 messages to the write end in one shot */ + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n" + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); /* EOF signals end of input to the server */ + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* Install alarm to fail the test if cbm_mcp_server_run blocks */ + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + + alarm(0); /* cancel alarm */ + signal(SIGALRM, SIG_DFL); + + ASSERT_EQ(rc, 0); + + /* Verify both responses are present: + * id:1 — initialize response + * id:2 — tools/list response (notifications/initialized produces none) + * and that the tools list payload is included. */ + rewind(out_fp); + char buf[4096] = {0}; + size_t nread = fread(buf, 1, sizeof(buf) - 1, out_fp); + ASSERT_TRUE(nread > 0); + ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":2")); + ASSERT_NOT_NULL(strstr(buf, "tools")); + + cbm_mcp_server_free(srv); + fclose(out_fp); + /* in_fp already EOF; fclose cleans up */ + fclose(in_fp); + PASS(); +} + +TEST(mcp_stdio_output_has_only_jsonrpc_messages) { + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + ASSERT_EQ(nread, (size_t)out_len); + buf[nread] = '\0'; + + int jsonrpc_lines = 0; + char *line = buf; + while (line && *line) { + char *next = strchr(line, '\n'); + if (next) { + *next = '\0'; + } + if (*line != '\0') { + ASSERT_EQ(line[0], '{'); + ASSERT_NOT_NULL(strstr(line, "\"jsonrpc\":\"2.0\"")); + size_t line_len = strlen(line); + while (line_len > 0 && (line[line_len - 1] == '\r' || line[line_len - 1] == ' ' || + line[line_len - 1] == '\t')) { + line_len--; + } + ASSERT_TRUE(line_len > 0); + ASSERT_EQ(line[line_len - 1], '}'); + jsonrpc_lines++; + } + line = next ? next + 1 : NULL; + } + ASSERT_EQ(jsonrpc_lines, 2); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +TEST(mcp_hidden_tools_reveal_sends_list_changed) { + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + const char *msgs = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"_hidden_tools\",\"arguments\":{}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); + const char *reveal_response = strstr(buf, "\"id\":2"); + ASSERT_NOT_NULL(strstr(buf, "\"id\":3")); + const char *list_changed = strstr(buf, "notifications/tools/list_changed"); + ASSERT_NOT_NULL(reveal_response); + ASSERT_NOT_NULL(list_changed); + ASSERT_TRUE(reveal_response < list_changed); + ASSERT_EQ(count_substr_mcp(buf, "\"name\":\"index_repository\""), 1); + ASSERT_EQ(count_substr_mcp(buf, "\"name\":\"get_architecture\""), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +TEST(mcp_codex_static_catalog_needs_no_reveal_notification) { + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + const char *msgs = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"codex-mcp-client\",\"version\":\"1.2.3\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"_hidden_tools\",\"arguments\":{}}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_EQ(written, (ssize_t)strlen(msgs)); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + ASSERT_EQ(nread, (size_t)out_len); + buf[nread] = '\0'; + + ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":2")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":3")); + ASSERT_NOT_NULL(strstr(buf, "\"name\":\"check_index_coverage\"")); + ASSERT_NOT_NULL(strstr(buf, "\"name\":\"index_repository\"")); + ASSERT_NULL(strstr(buf, "notifications/tools/list_changed")); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +TEST(mcp_hidden_tools_reveal_frames_list_changed) { + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + enum { FRAME_BUF_SIZE = CBM_SZ_2K }; + char msgs[FRAME_BUF_SIZE]; + char *cursor = msgs; + size_t remaining = sizeof(msgs); + ASSERT_TRUE(append_content_length_frame( + &cursor, &remaining, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}")); + ASSERT_TRUE(append_content_length_frame( + &cursor, &remaining, + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"_hidden_tools\",\"arguments\":{}}}")); + ASSERT_TRUE(append_content_length_frame( + &cursor, &remaining, + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}")); + + size_t msg_len = (size_t)(cursor - msgs); + ssize_t written = write(fds[1], msgs, msg_len); + ASSERT_TRUE(written == (ssize_t)msg_len); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_EQ(count_substr_mcp(buf, "Content-Length:"), 4); + const char *reveal_response = strstr(buf, "\"id\":2"); + const char *list_changed = strstr(buf, "notifications/tools/list_changed"); + ASSERT_NOT_NULL(reveal_response); + ASSERT_NOT_NULL(list_changed); + ASSERT_TRUE(reveal_response < list_changed); + ASSERT_EQ(count_substr_mcp(buf, "\"name\":\"index_repository\""), 1); + ASSERT_EQ(count_substr_mcp(buf, "\"name\":\"get_architecture\""), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +/* RED against the pre-Change-2 cbm_mcp_server_notify_index_published, which + * only marked cache-staleness atomics and never queued a notification (its + * one caller was the hidden-tools reveal path above, not the general + * publication authority every index/autoindex/delete/dependency pathway + * calls). This drives the notify function directly — the same entry point + * every stage-2 publication call site uses — rather than through + * _hidden_tools, so it proves the general pending-flag/drain mechanism + * independent of that one call site. Two notify() calls before any request + * is processed must still coalesce into exactly one notification (no + * notification storm), and it must arrive only once tools/list has been + * served (mcp_tools_list_already_served), never before. */ +TEST(mcp_notify_index_published_sends_list_changed_once) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_notify_index_published(srv); + cbm_mcp_server_notify_index_published(srv); /* two publishers racing */ + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + const char *msgs = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + const char *resp1 = strstr(buf, "\"id\":1"); + const char *notif = strstr(buf, "notifications/tools/list_changed"); + ASSERT_NOT_NULL(resp1); + ASSERT_NOT_NULL(strstr(buf, "\"id\":2")); + ASSERT_NOT_NULL(notif); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + /* Drain contract: notification bytes strictly follow the first served + * tools/list response — never interleaved before it. */ + ASSERT_TRUE(notif > resp1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +/* A publication invalidates both the cached query_graph description and the + * cached SQLite handle. The next protocol tools/list must reopen the store, + * advertise newly published vocabulary, and coalesce repeated publication + * signals into one post-response notification. */ +TEST(mcp_published_schema_refreshes_description_once) { + const char *project = "mcp_published_schema_refresh_fixture"; + const char *cache = cbm_resolve_cache_dir(); + char db_path[CBM_SZ_4K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + + cbm_store_t *seed = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(seed); + ASSERT_EQ(cbm_store_upsert_project(seed, project, "/tmp/published-schema-refresh"), + CBM_STORE_OK); + cbm_node_t initial = {.project = project, + .label = "InitialSchemaLabel", + .name = "initial", + .qualified_name = "mcp_published_schema_refresh_fixture.initial", + .file_path = "initial.c"}; + ASSERT_GT(cbm_store_upsert_node(seed, &initial), 0); + cbm_store_close(seed); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, project); + char *before = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(before); + ASSERT_NOT_NULL(strstr(before, "InitialSchemaLabel")); + ASSERT_NULL(strstr(before, "PublishedOnlyLabel")); + free(before); + + cbm_store_t *writer = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(writer); + cbm_node_t published = {.project = project, + .label = "PublishedOnlyLabel", + .name = "published", + .qualified_name = "mcp_published_schema_refresh_fixture.published", + .file_path = "published.c"}; + ASSERT_GT(cbm_store_upsert_node(writer, &published), 0); + cbm_store_close(writer); + + cbm_mcp_server_notify_index_published(srv); + cbm_mcp_server_notify_index_published(srv); + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_EQ(written, (ssize_t)strlen(msgs)); + close(fds[1]); + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_EQ(count_substr_mcp(buf, "PublishedOnlyLabel"), 2); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + cleanup_project_db(cache, project); + PASS(); +} + +/* Inverse of the above: the handshake-proxy's actual promise is that a + * session which publishes but never serves a tools/list emits ZERO + * notifications — untested until now. A single non-tools/list request + * (ping) must leave mcp_tools_list_already_served's gate closed. */ +TEST(mcp_notify_before_any_tools_list_suppressed) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_notify_index_published(srv); + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 0); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +/* Coverage-matrix gap (stage 2, Change 7): RED against the pre-Change-7 + * handle_delete_project, which closed the store and freed current_project + * but never staled the cached description — a client that deleted a + * project kept seeing its schema forever. Seeds a real on-disk project .db + * at the exact path project_db_path() resolves (cache_dir/.db) so + * the assertion can require "status":"deleted" — proving the mutating + * delete branch actually ran, not just the unconditional-notify shortcut + * a not-found project would also hit. */ +TEST(mcp_delete_project_sends_list_changed) { + const char *project = "mcp_delete_project_sends_list_changed_fixture"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + + cbm_store_t *seed = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(seed); + ASSERT_EQ(cbm_store_upsert_project(seed, project, "/tmp/delete-project-fixture"), + CBM_STORE_OK); + cbm_node_t seed_node = {.project = project, + .label = "DeletedProjectOnlyLabel", + .name = "seed_fn", + .qualified_name = "mcp_delete_project_sends_list_changed_fixture.seed_fn", + .file_path = "seed.go"}; + ASSERT_GT(cbm_store_upsert_node(seed, &seed_node), 0); + cbm_store_close(seed); + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + char msgs[CBM_SZ_1K]; + int n = snprintf(msgs, sizeof(msgs), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"delete_project\",\"arguments\":{\"project\":\"%s\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}\n", + project); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(msgs)); + ssize_t written = write(fds[1], msgs, (size_t)n); + ASSERT_TRUE(written == (ssize_t)n); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, project); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); + ASSERT_NOT_NULL(strstr(buf, "\"status\":\"deleted\"")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":3")); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + /* The first tools/list advertises the seeded schema; after deletion the + * relist must not reuse that cached description. */ + ASSERT_EQ(count_substr_mcp(buf, "DeletedProjectOnlyLabel"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} + +/* ISSUE-1 (fragility audit 2026-07-18): deleting a project that was never + * indexed (no .db file) is a no-op, not a mutation — it must NOT invalidate + * the tools/list description cache or queue a list_changed notification. + * Sibling of mcp_delete_project_sends_list_changed, which proves the + * positive (real delete) direction; this proves the gate stays closed on + * the no-op/error direction. */ +TEST(mcp_delete_project_noop_sends_no_list_changed) { + const char *project = "mcp_delete_project_noop_no_list_changed_fixture"; + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + char msgs[CBM_SZ_1K]; + int n = snprintf(msgs, sizeof(msgs), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"delete_project\",\"arguments\":{\"project\":\"%s\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}\n", + project); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(msgs)); + ssize_t written = write(fds[1], msgs, (size_t)n); + ASSERT_TRUE(written == (ssize_t)n); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); + /* isError responses carry only the escaped "text" field (no + * structuredContent — cbm_mcp_text_result only adds that on the + * non-error path), so the literal unescaped "status":"not_found" never + * appears; match the unescaped status value instead. */ + ASSERT_NOT_NULL(strstr(buf, "not_found")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":3")); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 0); + + free(buf); cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + PASS(); +} - cbm_store_t *check = cbm_store_open_path_query(db_path); - bool valid_generation = check && cbm_store_check_integrity(check); - cbm_project_t stored_project = {0}; - bool replacement_root_visible = - check && cbm_store_get_project(check, project, &stored_project) == CBM_STORE_OK && - stored_project.root_path && strcmp(stored_project.root_path, replacement_root) == 0; - cbm_project_free_fields(&stored_project); - cbm_store_close(check); - char unexpected_backup[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); - bool live_exists = cbm_file_exists(db_path); - bool replacement_consumed = !cbm_file_exists(replacement_path); - int begin_count = replacement.guard.begin_count; - int end_count = replacement.guard.end_count; - bool guarded_project = begin_count == 1 && end_count == 1 && - strcmp(replacement.guard.begin_projects[0], project) == 0 && - strcmp(replacement.guard.end_projects[0], project) == 0; - bool replacement_attempted = replacement.replacement_attempted; - bool replacement_succeeded = replacement.replacement_succeeded; +/* Coverage-matrix gap (stage 2, Change 5): RED against the pre-Change-5 + * in-process handle_index_repository, which published a new graph via the + * degraded (non-supervised) path without staling the cached description. + * index_supervisor_gate_requires_marked_host_issue845 above proves an + * unmarked host (this test binary, absent cbm_index_supervisor_mark_host()) + * always takes this in-process branch, so no supervisor env juggling is + * needed here. */ +TEST(mcp_index_repository_inprocess_sends_list_changed) { + char tmp_dir[CBM_SZ_256]; + snprintf(tmp_dir, sizeof(tmp_dir), "%s/cbm-idx5-repo-XXXXXX", cbm_resolve_cache_dir()); + ASSERT_TRUE(cbm_mkdtemp(tmp_dir)); + char src_path[CBM_SZ_512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *seed_fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(seed_fp); + fputs("def main():\n return 'ok'\n", seed_fp); + fclose(seed_fp); - mcp_cleanup_corrupt_backups(cache, project); + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + char msgs[CBM_SZ_1K]; + int n = snprintf(msgs, sizeof(msgs), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s\",\"mode\":\"fast\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}\n", + tmp_dir); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(msgs)); + ssize_t written = write(fds[1], msgs, (size_t)n); + ASSERT_TRUE(written == (ssize_t)n); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_NOT_NULL(strstr(buf, "\"status\":\"indexed\"")); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + th_rmtree(tmp_dir); + PASS(); +} + +#endif /* !_WIN32 */ + +TEST(mcp_incremental_artifact_failure_reports_published_graph) { + const char *cache = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache); + char repo[CBM_SZ_512]; + snprintf(repo, sizeof(repo), "%s/cbm-mcp-artifact-failure-XXXXXX", cache); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + + char source_path[CBM_SZ_1K]; + snprintf(source_path, sizeof(source_path), "%s/main.c", repo); + FILE *source = cbm_fopen(source_path, "wb"); + ASSERT_NOT_NULL(source); + ASSERT_TRUE(fputs("int before_artifact_failure(void) { return 0; }\n", source) >= 0); + ASSERT_EQ(fclose(source), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char repo_json[CBM_SZ_4K]; + ASSERT_GT(cbm_json_escape(repo_json, sizeof(repo_json), repo), 0); + char args[CBM_SZ_4K]; + int args_len = + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_json); + ASSERT_TRUE(args_len > 0 && (size_t)args_len < sizeof(args)); + char *initial = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(initial); + ASSERT_TRUE(cbm_mcp_index_response_published(initial)); + free(initial); + + char artifact_dir[CBM_SZ_1K]; + snprintf(artifact_dir, sizeof(artifact_dir), "%s/.codebase-memory", repo); + ASSERT_TRUE(cbm_mkdir_p(artifact_dir, 0755)); + char artifact_path[CBM_SZ_1K]; + snprintf(artifact_path, sizeof(artifact_path), "%s/graph.db.zst", artifact_dir); + ASSERT_TRUE(cbm_mkdir_p(artifact_path, 0755)); + + source = cbm_fopen(source_path, "wb"); + ASSERT_NOT_NULL(source); + ASSERT_TRUE(fputs("int after_artifact_failure_is_longer(void) { return 1; }\n", source) >= 0); + ASSERT_EQ(fclose(source), 0); + + args_len = snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"fast\",\"persistence\":true}", repo_json); + ASSERT_TRUE(args_len > 0 && (size_t)args_len < sizeof(args)); + char *response = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(response); + ASSERT_TRUE(cbm_mcp_index_response_published(response)); + char *inner = extract_text_content(response); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"publish_kind\":\"incremental_exact\"")); + ASSERT_NOT_NULL(strstr(inner, "\"graph_published\":true")); + ASSERT_NOT_NULL(strstr(inner, "\"status\":\"degraded\"")); + ASSERT_NOT_NULL( + strstr(inner, "\"error\":\"graph published, but persistence artifact export failed\"")); + ASSERT_NOT_NULL(strstr(inner, "\"artifact_present\":false")); + free(inner); + free(response); + + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); + char project_json[CBM_SZ_4K]; + ASSERT_GT(cbm_json_escape(project_json, sizeof(project_json), project), 0); + char query_args[CBM_SZ_4K]; + int query_len = + snprintf(query_args, sizeof(query_args), + "{\"project\":\"%s\",\"name_pattern\":\"after_artifact_failure_is_longer\"," + "\"format\":\"json\"}", + project_json); + ASSERT_TRUE(query_len > 0 && (size_t)query_len < sizeof(query_args)); + char *query = cbm_mcp_handle_tool(srv, "search_graph", query_args); + ASSERT_NOT_NULL(query); + ASSERT_NOT_NULL(strstr(query, "after_artifact_failure_is_longer")); + free(query); + + cbm_mcp_server_free(srv); cleanup_project_db(cache, project); - cbm_unlink(replacement_path); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); + free(project); + ASSERT_EQ(th_rmtree(repo), 0); + PASS(); +} - ASSERT_TRUE(replacement_attempted); - ASSERT_TRUE(replacement_succeeded); - ASSERT_TRUE(guarded_project); - ASSERT_TRUE(response_used_replacement); - ASSERT_TRUE(live_exists); - ASSERT_TRUE(replacement_consumed); - ASSERT_TRUE(valid_generation); - ASSERT_TRUE(replacement_root_visible); - ASSERT_EQ(backup_count, 0); +#ifndef _WIN32 + +/* Coverage-matrix gap (stage 2, Change 6): RED against the pre-Change-6 + * background autoindex_thread (rc==0 branch), which published a fresh graph + * from initialize-driven session auto-index without staling the cached + * description. cbm_mcp_server_join_autoindex waits deterministically for + * the background thread instead of sleeping/polling. */ +TEST(mcp_autoindex_thread_sends_list_changed) { + char tmp_dir[CBM_SZ_256]; + snprintf(tmp_dir, sizeof(tmp_dir), "%s/cbm-idx6-repo-XXXXXX", cbm_resolve_cache_dir()); + ASSERT_TRUE(cbm_mkdtemp(tmp_dir)); + char src_path[CBM_SZ_512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *seed_fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(seed_fp); + fputs("def main():\n return 'ok'\n", seed_fp); + fclose(seed_fp); + + char old_cwd[CBM_SZ_1K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + ASSERT_EQ(cbm_chdir(tmp_dir), 0); + + cbm_config_t *cfg = cbm_config_open(tmp_dir); + ASSERT_NOT_NULL(cfg); + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "false"); + + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + const char *init_msg = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n"; + ssize_t written = write(fds[1], init_msg, strlen(init_msg)); + ASSERT_TRUE(written > 0); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + fclose(in_fp); + + /* Deterministic wait for the background publish instead of a sleep. */ + (void)cbm_mcp_server_join_autoindex(srv); + ASSERT_EQ(cbm_chdir(old_cwd), 0); + + int fds2[2]; + ASSERT_EQ(pipe(fds2), 0); + const char *list_msgs = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}\n"; + written = write(fds2[1], list_msgs, strlen(list_msgs)); + ASSERT_TRUE(written > 0); + close(fds2[1]); + FILE *in_fp2 = fdopen(fds2[0], "r"); + ASSERT_NOT_NULL(in_fp2); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + rc = cbm_mcp_server_run(srv, in_fp2, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp2); + cbm_config_close(cfg); + th_rmtree(tmp_dir); PASS(); } -/* A fixed `.corrupt` destination is itself user recovery data. A later - * quarantine must retain it byte-for-byte and choose a distinct backup name - * rather than unlinking the previous incident before rename. */ -TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) { - char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-unique-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); +/* Coverage-matrix gap (stage 2, Change 8): RED against the pre-Change-8 + * handle_index_dependencies, which mutated project.dep.* graphs and + * cross-boundary edges without staling the cached description. The notify + * call sits after the unconditional cbm_pagerank_compute_with_config at the + * end of the handler, so a project with zero real dependencies still + * reaches it. */ +TEST(mcp_index_dependencies_sends_list_changed) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "mcp_index_dependencies_sends_list_changed_fixture"; + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/index-deps-fixture"), + CBM_STORE_OK); - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + char empty_dir[CBM_SZ_256]; + snprintf(empty_dir, sizeof(empty_dir), "%s/cbm-idx8-deps-XXXXXX", cbm_resolve_cache_dir()); + ASSERT_TRUE(cbm_mkdtemp(empty_dir)); - const char *project = "guard-corrupt-unique"; + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + char msgs[CBM_SZ_1K]; + int n = snprintf(msgs, sizeof(msgs), + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_dependencies\"," + "\"arguments\":{\"project\":\"%s\",\"packages\":[\"nonexistent-pkg\"]," + "\"source_paths\":[\"%s\"]}}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{}}\n", + project, empty_dir); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(msgs)); + ssize_t written = write(fds[1], msgs, (size_t)n); + ASSERT_TRUE(written == (ssize_t)n); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":2")); + ASSERT_NOT_NULL(strstr(buf, "\"id\":3")); + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); + th_rmtree(empty_dir); + PASS(); +} + +/* Coverage-matrix gap (stage 2, Change 10): RED against the pre-Change-10 + * overlay_compaction_thread, which promoted a ready overlay generation to + * canonical rows without staling the cached description — overlay facts + * became canonical but the advertised schema never caught up. Builds one + * minimal base generation plus a ready, compactable overlay generation + * directly on the store (same idiom as + * tests/test_store_nodes.c:store_compact_ready_overlay_generations_respects_batch_limit) + * rather than running a full pipeline. */ +TEST(mcp_overlay_compaction_sends_list_changed) { + enum { MCP_OC_BASE_GENERATION = 1, MCP_OC_MAX_GENERATIONS = 10 }; + const char *project = "mcp_overlay_compaction_sends_list_changed_fixture"; + + /* overlay_compaction_thread reopens the store from disk via + * project_db_path() (it runs independently of any in-memory srv store), + * so the fixture must be a real on-disk .db at that exact path — an + * in-memory-only store here makes the worker fail with + * CBM_STORE_NOT_FOUND. */ char db_path[CBM_SZ_1K]; - char existing_backup_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - snprintf(existing_backup_path, sizeof(existing_backup_path), "%s.corrupt", db_path); - ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); - ASSERT_EQ(th_write_file(existing_backup_path, "previous-backup-must-survive\n"), 0); + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/overlay-compact-fixture"), + CBM_STORE_OK); - long existing_len = 0; - unsigned char *existing_before = mcp_read_file_bytes(existing_backup_path, &existing_len); - ASSERT_NOT_NULL(existing_before); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(store, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, MCP_OC_BASE_GENERATION); + + cbm_node_t base_node = { + .project = project, + .label = "Function", + .name = "base_fn", + .qualified_name = "mcp_overlay_compaction_sends_list_changed_fixture.base_fn", + .file_path = "base.go"}; + cbm_store_file_delta_t base_delta = {.project = project, + .rel_path = "base.go", + .generation = generation, + .nodes = &base_node, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_file_delta(store, &base_delta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(store, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ( + cbm_store_reserve_overlay_generation(store, project, generation, &overlay_generation), + CBM_STORE_OK); + cbm_store_file_delta_t delete_base = {.project = project, + .rel_path = "base.go", + .generation = generation, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(store, &delete_base, overlay_generation), + CBM_STORE_OK); + cbm_store_close(store); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &probe); - char *resp = cbm_mcp_handle_tool( - srv, "search_graph", "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}"); - free(resp); - cbm_mcp_server_free(srv); + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); - bool existing_unchanged = - mcp_file_matches_snapshot(existing_backup_path, existing_before, existing_len); - free(existing_before); - char unique_backup_path[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, unique_backup_path, sizeof(unique_backup_path)); - cbm_store_t *quarantined = - unique_backup_path[0] ? cbm_store_open_path_query(unique_backup_path) : NULL; - bool unique_backup_is_corrupt = quarantined && !cbm_store_check_integrity(quarantined); - cbm_store_close(quarantined); - bool live_removed = !cbm_file_exists(db_path); - int begin_count = probe.begin_count; - int end_count = probe.end_count; - bool guarded_project = begin_count == 1 && end_count == 1 && - strcmp(probe.begin_projects[0], project) == 0 && - strcmp(probe.end_projects[0], project) == 0; + ASSERT_TRUE(cbm_mcp_server_start_overlay_compaction(srv, project, MCP_OC_MAX_GENERATIONS)); + int compacted = 0; + ASSERT_EQ(cbm_mcp_server_join_overlay_compaction(srv, &compacted), 0); + ASSERT_TRUE(compacted > 0); - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); - ASSERT_TRUE(guarded_project); - ASSERT_TRUE(existing_unchanged); - ASSERT_EQ(backup_count, 2); - ASSERT_TRUE(unique_backup_path[0] != '\0'); - ASSERT_TRUE(unique_backup_is_corrupt); - ASSERT_TRUE(live_removed); + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + signal(SIGALRM, alarm_handler); + alarm(MCP_STDIO_TEST_TIMEOUT_SECONDS); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + alarm(0); + signal(SIGALRM, SIG_DFL); + ASSERT_EQ(rc, 0); + + ASSERT_EQ(fseek(out_fp, 0, SEEK_END), 0); + long out_len = ftell(out_fp); + ASSERT_TRUE(out_len > 0); + rewind(out_fp); + char *buf = malloc((size_t)out_len + 1); + ASSERT_NOT_NULL(buf); + size_t nread = fread(buf, 1, (size_t)out_len, out_fp); + buf[nread] = '\0'; + + ASSERT_EQ(count_substr_mcp(buf, "notifications/tools/list_changed"), 1); + + free(buf); + cbm_mcp_server_free(srv); + fclose(out_fp); + fclose(in_fp); PASS(); } +#endif /* !_WIN32 */ -/* Deterministically fail immediately before atomic snapshot publication on - * every platform. The incomplete pending copy must be removed while the live - * DB and its committed WAL remain byte-for-byte untouched. */ -TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { +/* Issue #235: passing an unrecognised project name to a tool crashed the + * binary with a buffer overflow while building the "available_projects" + * error list — collect_db_project_names overflowed projects[CBM_SZ_4K] via + * an unsigned underflow on (out_sz - offset) once the listed names exceeded + * the buffer. Fill a temp cache dir with enough long-named .db files to + * exceed 4 KB, then hit the bad-project path. Under ASan a regression aborts + * here; the fixed bounds-check keeps it clean and returns a normal error. */ +#define ISSUE235_DBNAME(buf, dir, i) \ + snprintf((buf), sizeof(buf), \ + "%s/proj_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \ + (dir), (i)) +TEST(tool_bad_project_name_no_overflow_issue235) { char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-publish-fail-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails */ + } - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - const char *project = "guard-corrupt-publish-fail"; - char db_path[CBM_SZ_1K]; - char wal_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); - cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); - ASSERT_NOT_NULL(writer); - ASSERT_TRUE(cbm_file_exists(wal_path)); - - long db_len = 0; - long wal_len = 0; - unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); - unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); - ASSERT_NOT_NULL(db_before); - ASSERT_NOT_NULL(wal_before); - ASSERT_TRUE(db_len > 0); - ASSERT_TRUE(wal_len > 0); + /* 40 * ~120-char names overflows the 4 KB available-projects buffer. + * collect_db_project_names advertises each db's INTERNAL project name + * (#704), so the fixture must hold valid dbs with long internal names — + * not stub files — for the bounds-check path to actually be exercised. */ + enum { ISSUE235_N = 40 }; + for (int i = 0; i < ISSUE235_N; i++) { + char name[512]; + ISSUE235_DBNAME(name, cache, i); + char iname[256]; + snprintf(iname, sizeof(iname), + "proj_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + i); + cbm_store_t *st = cbm_store_open_path(name); + if (st) { + cbm_store_upsert_project(st, iname, cache); + cbm_store_close(st); + } + } cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t guard = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &guard); - mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"}; - cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); - char *resp = - cbm_mcp_handle_tool(srv, "search_graph", - "{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}"); - - bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); - bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); - char unexpected_backup[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); - int artifact_count = mcp_count_corrupt_artifacts(cache, project); - int begin_count = guard.begin_count; - int end_count = guard.end_count; - bool guarded_project = begin_count == 1 && end_count == 1 && - strcmp(guard.begin_projects[0], project) == 0 && - strcmp(guard.end_projects[0], project) == 0; - bool failed_at_publish = - hook.call_count == 1 && strcmp(hook.steps[0], "before_snapshot_publish") == 0; - + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":" + "\"search_graph\",\"arguments\":{\"label\":\"Function\"," + "\"project\":\"definitely-not-a-real-project-xyz\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not found")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_NOT_NULL(root); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(root, "total_count")), ISSUE235_N); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "available_projects_truncated"))); + yyjson_val *projects = yyjson_obj_get(root, "available_projects"); + ASSERT_TRUE(yyjson_is_arr(projects)); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(root, "count")), + (int)yyjson_arr_size(projects)); + ASSERT_TRUE((int)yyjson_arr_size(projects) < ISSUE235_N); + yyjson_doc_free(doc); + free(inner); free(resp); cbm_mcp_server_free(srv); - free(db_before); - free(wal_before); - cbm_store_close(writer); - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); - cbm_rmdir(cache); - ASSERT_TRUE(failed_at_publish); - ASSERT_TRUE(guarded_project); - ASSERT_TRUE(db_unchanged); - ASSERT_TRUE(wal_unchanged); - ASSERT_EQ(backup_count, 0); - ASSERT_EQ(artifact_count, 0); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + for (int i = 0; i < ISSUE235_N; i++) { + char name[512]; + ISSUE235_DBNAME(name, cache, i); + cbm_unlink(name); + char side[540]; + snprintf(side, sizeof(side), "%s-wal", name); + cbm_unlink(side); + snprintf(side, sizeof(side), "%s-shm", name); + cbm_unlink(side); + } + cbm_rmdir(cache); PASS(); } +#undef ISSUE235_DBNAME -/* Once the recovery snapshot is atomically visible, a crash/failure before - * deleting the live generation may leave both copies. The live DB/WAL must be - * unchanged, and the published backup must already contain committed WAL data - * as one self-contained SQLite database. */ -TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { +/* Issue #235 (follow-up): with many long-named projects indexed, + * collect_db_project_names overflowed projects[CBM_SZ_4K] and truncated the + * LAST name MID-TOKEN, then clamped offset to out_sz-1 — emitting malformed, + * unterminated JSON like + * ...,"available_projects":["a",...,"vjson_49_bbb],"count":50} + * (unclosed string + unclosed array). build_project_list_error wrapped that + * invalid body into the tool error, so a "project not found" reply was NOT + * valid JSON once enough projects were indexed. + * + * Reproduce-first: fill an isolated cache dir with enough long INTERNAL-named + * dbs to overflow the 4 KB buffer, hit the bad-project path, then assert the + * ERROR BODY (the inner MCP text content) parses as valid JSON and that + * available_projects is a JSON array whose length == count. RED on the + * truncating code (yyjson_read returns NULL on the mid-token cut); GREEN after + * the element-boundary fix, which only ever writes whole "name" tokens. */ +#define BADPROJ_JSON_DBNAME(buf, dir, i) \ + snprintf((buf), sizeof(buf), \ + "%s/vjson_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \ + (dir), (i)) +TEST(tool_bad_project_error_valid_json_issue235) { char cache[256]; - snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-after-publish-XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-vjson-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails */ + } - const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - const char *project = "guard-corrupt-after-publish"; - char db_path[CBM_SZ_1K]; - char wal_path[CBM_SZ_1K]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); - cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); - ASSERT_NOT_NULL(writer); - ASSERT_TRUE(cbm_file_exists(wal_path)); - - long db_len = 0; - long wal_len = 0; - unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); - unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); - ASSERT_NOT_NULL(db_before); - ASSERT_NOT_NULL(wal_before); + /* 50 * ~120-char INTERNAL names >> 4 KB → the available_projects buffer + * overflows and the last name is cut mid-token on the unfixed code. */ + enum { BADPROJ_N = 50 }; + for (int i = 0; i < BADPROJ_N; i++) { + char name[512]; + BADPROJ_JSON_DBNAME(name, cache, i); + char iname[256]; + snprintf(iname, sizeof(iname), + "vjson_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + i); + cbm_store_t *st = cbm_store_open_path(name); + if (st) { + cbm_store_upsert_project(st, iname, cache); + cbm_store_close(st); + } + } cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - mcp_mutation_guard_probe_t guard = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &guard); - mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"}; - cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); - char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-after-publish\",\"name_pattern\":\".*\"}"); - - bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); - bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); - char backup_path[CBM_SZ_1K]; - int backup_count = mcp_find_corrupt_backups(cache, project, backup_path, sizeof(backup_path)); - int artifact_count = mcp_count_corrupt_artifacts(cache, project); - cbm_store_t *snapshot = backup_path[0] ? cbm_store_open_path_query(backup_path) : NULL; - cbm_project_t recovered = {0}; - bool recovered_wal_project = - snapshot && cbm_store_get_project(snapshot, project, &recovered) == CBM_STORE_OK && - recovered.root_path && strcmp(recovered.root_path, "826") == 0; - cbm_project_free_fields(&recovered); - cbm_store_close(snapshot); - char backup_wal[CBM_SZ_2K]; - char backup_shm[CBM_SZ_2K]; - snprintf(backup_wal, sizeof(backup_wal), "%s-wal", backup_path); - snprintf(backup_shm, sizeof(backup_shm), "%s-shm", backup_path); - bool snapshot_self_contained = !cbm_file_exists(backup_wal) && !cbm_file_exists(backup_shm); - bool hook_order = hook.call_count == 2 && - strcmp(hook.steps[0], "before_snapshot_publish") == 0 && - strcmp(hook.steps[1], "after_snapshot_publish") == 0; - bool guard_balanced = guard.begin_count == 1 && guard.end_count == 1 && - strcmp(guard.begin_projects[0], project) == 0 && - strcmp(guard.end_projects[0], project) == 0; + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":" + "\"search_graph\",\"arguments\":{\"label\":\"Function\"," + "\"project\":\"definitely-not-a-real-project-xyz\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not found")); - free(resp); - cbm_mcp_server_free(srv); - free(db_before); - free(wal_before); - cbm_store_close(writer); - mcp_cleanup_corrupt_backups(cache, project); - cleanup_project_db(cache, project); - restore_cache_dir(saved_cache_copy); - free(saved_cache_copy); + /* The inner MCP text content is the error body built by + * build_project_list_error. Capture its validity BEFORE cleanup so a RED + * failure still restores the environment. */ + char *body = extract_text_content(resp); + bool body_valid = false; + bool aps_ok = false; /* available_projects is an array whose len == count */ + if (body) { + yyjson_doc *bdoc = yyjson_read(body, strlen(body), 0); + if (bdoc) { + body_valid = true; + yyjson_val *broot = yyjson_doc_get_root(bdoc); + yyjson_val *aps = yyjson_obj_get(broot, "available_projects"); + yyjson_val *cnt = yyjson_obj_get(broot, "count"); + if (aps && yyjson_is_arr(aps) && cnt && yyjson_is_int(cnt)) { + aps_ok = (yyjson_arr_size(aps) == (size_t)yyjson_get_int(cnt)); + } + yyjson_doc_free(bdoc); + } + } + free(body); + free(resp); + cbm_mcp_server_free(srv); + + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + for (int i = 0; i < BADPROJ_N; i++) { + char name[512]; + BADPROJ_JSON_DBNAME(name, cache, i); + cbm_unlink(name); + char side[540]; + snprintf(side, sizeof(side), "%s-wal", name); + cbm_unlink(side); + snprintf(side, sizeof(side), "%s-shm", name); + cbm_unlink(side); + } cbm_rmdir(cache); - ASSERT_TRUE(hook_order); - ASSERT_TRUE(guard_balanced); - ASSERT_TRUE(db_unchanged); - ASSERT_TRUE(wal_unchanged); - ASSERT_EQ(backup_count, 1); - ASSERT_EQ(artifact_count, 1); - ASSERT_TRUE(recovered_wal_project); - ASSERT_TRUE(snapshot_self_contained); + /* RED on the unfixed code: mid-token truncation → invalid JSON body. */ + ASSERT_TRUE(body_valid); + ASSERT_TRUE(aps_ok); PASS(); } +#undef BADPROJ_JSON_DBNAME -TEST(tool_index_repository_reports_store_backed_adr) { - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); +/* ── #704: project resolution must key on the db's INTERNAL project name ── + * + * Issue #704: project resolution is registry-less and filename-addressed. + * resolve_store() opens /.db and then requires the internal + * `projects.name` row to equal the passed name; list_projects / + * collect_db_project_names derive the advertised name from the .db FILENAME. + * When a db's filename != its internal name (a legacy '.'-vs-'-' username + * twin, or a copied/renamed file) it shows up in list_projects under the + * filename, but every query returns "project not found" — node rows are + * tagged with the INTERNAL name, so neither the filename nor the resolve + * path lines up. The fix makes list + resolve both key on the INTERNAL name. + * + * Reproduce-first fixture in an isolated CBM_CACHE_DIR: + * - alpha704.db : filename == internal name "alpha704" (control / fast path) + * - gamma704.db : internal name "beta704" (DRIFT: built as + * beta704.db then renamed → filename != internal name) + * - ghost704.db : 0-byte file (ghost / unresolvable) + * + * RED on buggy code / GREEN on the fix: + * A. list_projects advertises "beta704" (internal), NOT "gamma704" (filename), + * and NOT "ghost704" (0-byte filtered). + * B. search_graph(project="beta704") resolves via the cache-dir scan and + * returns the node — not the "project not found" error. + * C. control project "alpha704" still resolves on the fast path. + * D. the 0-byte ghost is not resolvable. + * E. addressing the drifted db by its FILENAME ("gamma704") stays not-found + * (we key on the internal name, never the file on disk). + */ + +/* Create a file-backed project db at

/ whose INTERNAL project + * name is `internal` (which may differ from the filename), holding one + * Function node named `fn`. Returns true on success. */ +static bool issue704_make_db(const char *dir, const char *filename, const char *internal, + const char *fn) { + char path[700]; + snprintf(path, sizeof(path), "%s/%s", dir, filename); + cbm_store_t *st = cbm_store_open_path(path); + if (!st) { + return false; + } + bool ok = (cbm_store_upsert_project(st, internal, dir) == CBM_STORE_OK); + if (ok) { + char qn[256]; + snprintf(qn, sizeof(qn), "%s.%s", internal, fn); + cbm_node_t n = {0}; + n.project = internal; + n.label = "Function"; + n.name = fn; + n.qualified_name = qn; + n.file_path = "main.go"; + n.start_line = 1; + n.end_line = 2; + ok = (cbm_store_upsert_node(st, &n) > 0); } + cbm_store_close(st); + return ok; +} + +TEST(tool_resolve_store_by_internal_name_issue704) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-index-adr-cache-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-issue704-XXXXXX"); if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); + PASS(); /* skip if mkdtemp fails — not a #704 signal */ } const char *saved = getenv("CBM_CACHE_DIR"); char *saved_copy = saved ? strdup(saved) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return 'ok'\n", fp); - fclose(fp); + /* (1) control: filename == internal name */ + ASSERT_TRUE(issue704_make_db(cache, "alpha704.db", "alpha704", "alphaFunc704")); - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); + /* (2) DRIFT: build beta704.db (internal "beta704") then rename the file to + * gamma704.db, so filename "gamma704" != internal "beta704". */ + ASSERT_TRUE(issue704_make_db(cache, "beta704.db", "beta704", "betaFunc704")); + char beta_path[700]; + char gamma_path[700]; + snprintf(beta_path, sizeof(beta_path), "%s/beta704.db", cache); + snprintf(gamma_path, sizeof(gamma_path), "%s/gamma704.db", cache); + ASSERT_EQ(rename(beta_path, gamma_path), 0); + + /* (3) ghost: 0-byte db file */ + char ghost_path[700]; + snprintf(ghost_path, sizeof(ghost_path), "%s/ghost704.db", cache); + FILE *gp = fopen(ghost_path, "w"); + ASSERT_NOT_NULL(gp); + fclose(gp); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char args[1024]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - free(resp); + /* ── A: list_projects reports INTERNAL names; filters the ghost ── */ + char *list = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); + ASSERT_NOT_NULL(list); + ASSERT_NOT_NULL(strstr(list, "alpha704")); /* control */ + ASSERT_NOT_NULL(strstr(list, "beta704")); /* internal name of drifted db (RED before) */ + ASSERT_NULL(strstr(list, "gamma704")); /* filename must NOT be advertised (RED before) */ + ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */ + free(list); - char update_args[2048]; - snprintf(update_args, sizeof(update_args), - "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" - "Store-backed ADR metadata.\\n\"}", - project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); - free(resp); + /* ── B: the drifted project resolves by its INTERNAL name ──────── */ + char *q_beta = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"beta704\",\"name_pattern\":\"betaFunc704\",\"limit\":5}}}"); + ASSERT_NOT_NULL(q_beta); + ASSERT_NOT_NULL(strstr(q_beta, "betaFunc704")); /* resolved + returned node (RED before) */ + ASSERT_NULL(strstr(q_beta, "not found")); /* not the not-found error */ + free(q_beta); - resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); - ASSERT_NULL(strstr(resp, "adr_hint")); - free(resp); + /* ── C: control project still resolves on the fast path ────────── */ + char *q_alpha = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"alpha704\",\"name_pattern\":\"alphaFunc704\",\"limit\":5}}}"); + ASSERT_NOT_NULL(q_alpha); + ASSERT_NOT_NULL(strstr(q_alpha, "alphaFunc704")); + free(q_alpha); - char get_args[512]; - snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Store-backed ADR metadata.")); - ASSERT_NULL(strstr(resp, "no_adr")); - free(resp); + /* ── D: the 0-byte ghost is NOT resolvable ─────────────────────── */ + char *q_ghost = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"ghost704\",\"name_pattern\":\".*\",\"limit\":5}}}"); + ASSERT_NOT_NULL(q_ghost); + ASSERT_NOT_NULL(strstr(q_ghost, "not found")); + free(q_ghost); + + /* ── E: addressing the drifted db by its FILENAME stays not-found ── */ + char *q_gamma = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"gamma704\",\"name_pattern\":\".*\",\"limit\":5}}}"); + ASSERT_NOT_NULL(q_gamma); + ASSERT_NOT_NULL(strstr(q_gamma, "not found")); + free(q_gamma); cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); + + /* ── cleanup ───────────────────────────────────────────────────── */ + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + char a_path[700]; + snprintf(a_path, sizeof(a_path), "%s/alpha704.db", cache); + char corrupt_path[720]; + snprintf(corrupt_path, sizeof(corrupt_path), "%s.corrupt", ghost_path); + cbm_unlink(a_path); + cbm_unlink(gamma_path); + cbm_unlink(ghost_path); + cbm_unlink(corrupt_path); /* ghost may be quarantined by resolve_store */ + char side[740]; + snprintf(side, sizeof(side), "%s-wal", a_path); + cbm_unlink(side); + snprintf(side, sizeof(side), "%s-shm", a_path); + cbm_unlink(side); + snprintf(side, sizeof(side), "%s-wal", gamma_path); + cbm_unlink(side); + snprintf(side, sizeof(side), "%s-shm", gamma_path); + cbm_unlink(side); cbm_rmdir(cache); - cbm_rmdir(tmp_dir); PASS(); } -/* #1211: list_projects only ever advertises the project NAME, never the - * repo_path, but re-indexing by that same name (the natural next call) used - * to fall straight to "repo_path is required" because nothing resolved the - * name back to its stored root_path. Index once by repo_path, then re-index - * by project name alone and confirm it actually indexes instead of erroring. */ -TEST(tool_index_repository_resolves_root_path_from_project_name_issue1211) { - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-byname-test-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } +/* ── #1044: a "::missed" shadow row must not hide the project ── + * + * The miss-graph pass inserts a second `projects` row ("::missed") so + * its nodes satisfy the FK on nodes.project. db_internal_project_name + * required the projects table to hold EXACTLY ONE row, so any project with + * a miss graph vanished from list_projects and the graph UI, and the + * fallback-scan resolve path failed. + * + * RED on buggy code / GREEN on the fix: + * A. list_projects still advertises "delta1044" while the shadow row exists. + * B. the shadow name itself is never advertised. + * C. search_graph(project="delta1044") still resolves and returns the node. + */ +TEST(tool_list_projects_ignores_missed_shadow_issue1044) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-index-byname-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); + snprintf(cache, sizeof(cache), "/tmp/cbm-issue1044-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); /* skip if mkdtemp fails — not a #1044 signal */ } const char *saved = getenv("CBM_CACHE_DIR"); char *saved_copy = saved ? strdup(saved) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return 'ok'\n", fp); - fclose(fp); + ASSERT_TRUE(issue704_make_db(cache, "delta1044.db", "delta1044", "deltaFunc1044")); - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); + /* Add the shadow row exactly the way the miss-graph pass does. */ + char db_path[700]; + snprintf(db_path, sizeof(db_path), "%s/delta1044.db", cache); + cbm_store_t *st = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(st); + ASSERT_EQ(cbm_store_upsert_project(st, "delta1044::missed", ""), CBM_STORE_OK); + cbm_store_close(st); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char index_args[1024]; - snprintf(index_args, sizeof(index_args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", index_args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - free(resp); + /* ── A + B: primary advertised, shadow hidden ─────────────────── */ + char *list = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); + ASSERT_NOT_NULL(list); + ASSERT_NOT_NULL(strstr(list, "delta1044")); /* RED before: db skipped as ghost */ + ASSERT_NULL(strstr(list, "::missed")); /* shadow never advertised */ + free(list); - char by_name_args[512]; - snprintf(by_name_args, sizeof(by_name_args), "{\"project\":\"%s\",\"mode\":\"fast\"}", project); - resp = cbm_mcp_handle_tool(srv, "index_repository", by_name_args); - ASSERT_NOT_NULL(resp); - ASSERT_NULL(strstr(resp, "repo_path is required")); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - free(resp); + /* ── C: the project still resolves and returns its node ───────── */ + char *q = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{" + "\"project\":\"delta1044\",\"name_pattern\":\"deltaFunc1044\",\"limit\":5}}}"); + ASSERT_NOT_NULL(q); + ASSERT_NOT_NULL(strstr(q, "deltaFunc1044")); + ASSERT_NULL(strstr(q, "not found")); + free(q); cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); + + /* ── cleanup ───────────────────────────────────────────────────── */ + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + cbm_unlink(db_path); + char side1044[740]; + snprintf(side1044, sizeof(side1044), "%s-wal", db_path); + cbm_unlink(side1044); + snprintf(side1044, sizeof(side1044), "%s-shm", db_path); + cbm_unlink(side1044); cbm_rmdir(cache); - cbm_rmdir(tmp_dir); PASS(); } -/* Same gap, opposite outcome: a project name that was never indexed has no - * stored root_path to resolve, so it must still fail with the same clear - * "repo_path is required" error rather than a resolver crash or silent - * no-op. Guards the fallback path the fix above added. */ -TEST(tool_index_repository_unknown_project_name_still_requires_repo_path) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-index-byname-unknown-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); +/* ══════════════════════════════════════════════════════════════════ + * QUERY STORE READ-ONLY (data-integrity reproductions) + * + * Bug: query tools resolve the project store via resolve_store() -> + * cbm_store_open_path_query(), which opens the DB SQLITE_OPEN_READWRITE + * and runs configure_pragmas() with the WRITE pragmas + * (journal_mode=WAL + wal_checkpoint + synchronous). Two consequences: + * (a) read-only query tools MUTATE the on-disk DB (write pragmas), and + * (b) query tools FAIL outright on a read-only DB file / filesystem + * (the READWRITE open returns CANTOPEN -> resolve_store NULL -> + * "project not found"). + * Both tests below are written reproduce-first and are RED on the + * unfixed code, GREEN once query opens are READONLY with read-only + * pragmas. + * ══════════════════════════════════════════════════════════════════ */ + +#define ROQ_PROJECT "cbm-roq-test" + +/* Whole-file byte snapshot. Returns malloc'd buffer (caller frees) and + * writes the length to *out_len. Returns NULL on failure. */ +static unsigned char *roq_read_file_bytes(const char *path, long *out_len) { + *out_len = 0; + FILE *fp = fopen(path, "rb"); + if (!fp) { + return NULL; + } + if (fseek(fp, 0, SEEK_END) != 0) { + fclose(fp); + return NULL; + } + long sz = ftell(fp); + if (sz < 0) { + fclose(fp); + return NULL; + } + rewind(fp); + unsigned char *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1); + if (!buf) { + fclose(fp); + return NULL; + } + size_t got = fread(buf, 1, (size_t)sz, fp); + fclose(fp); + if (got != (size_t)sz) { + free(buf); + return NULL; + } + *out_len = sz; + return buf; +} + +static int roq_file_exists(const char *path) { + struct stat st; + return (stat(path, &st) == 0) ? 1 : 0; +} + +/* ── (a) NO-MUTATION ────────────────────────────────────────────────── + * + * readonly_query_does_not_mutate_db + * + * Create a real project DB, convert it to rollback (DELETE) journal mode + * on disk, snapshot its exact bytes, run search_graph through the server, + * then re-snapshot. The buggy query path runs `PRAGMA journal_mode=WAL`, + * which rewrites the file header (1,1 -> 2,2) and spawns a -wal sidecar — + * so the snapshots differ. The fixed READONLY path runs no write pragma, + * so the file is byte-identical. + * + * The DELETE-mode fixture is what makes the mutation OBSERVABLE: on an + * already-WAL file `journal_mode=WAL` is a silent no-op, so we deliberately + * stage the DB in rollback mode (the same technique repro_issue557 uses to + * plant a deterministic trigger). + * + * WHY RED on unfixed code: + * journal_mode=WAL rewrites the header -> memcmp(before, after) != 0 and + * a -wal file is created while the cached store is open. Both assertions + * that demand "unchanged" fire. + * ─────────────────────────────────────────────────────────────────── */ +TEST(readonly_query_does_not_mutate_db) { + char tmp_cache[512]; + snprintf(tmp_cache, sizeof(tmp_cache), "%s/cbm_roq_a_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmp_cache)) { + ASSERT_NOT_NULL(NULL); /* setup failure */ } const char *saved = getenv("CBM_CACHE_DIR"); char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_CACHE_DIR", tmp_cache, 1); + + char db_path[700]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, ROQ_PROJECT); + char wal_path[730]; + char shm_path[730]; + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); + + /* Build the DB and flip it to rollback journal mode on disk. */ + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, ROQ_PROJECT, "/tmp/roq"), CBM_STORE_OK); + cbm_node_t node = {.project = ROQ_PROJECT, + .label = "Function", + .name = "ReadOnlyProbe", + .qualified_name = "roq.mod.ReadOnlyProbe", + .file_path = "mod.c"}; + ASSERT_TRUE(cbm_store_upsert_node(setup, &node) > 0); + ASSERT_EQ(cbm_store_exec(setup, "PRAGMA journal_mode=DELETE;"), 0); + cbm_store_close(setup); + + /* Snapshot BEFORE any query. */ + long before_len = 0; + unsigned char *before = roq_read_file_bytes(db_path, &before_len); + ASSERT_NOT_NULL(before); + /* Run a query tool through the server (the resolve_store path). */ cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + char args[512]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*ReadOnlyProbe.*\"}", + ROQ_PROJECT); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", args); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", - "{\"project\":\"never-indexed-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "repo_path is required")); - free(resp); + /* Capture sidecar state WHILE the cached store is still open (the buggy + * RW+WAL open creates -wal here; on close it would be removed again). */ + int wal_while_open = roq_file_exists(wal_path); + int query_ok = (resp && strstr(resp, "ReadOnlyProbe") != NULL); + int query_failed = (resp && (strstr(resp, "not found") || strstr(resp, "not indexed"))); - cbm_mcp_server_free(srv); - restore_cache_dir(saved_copy); - free(saved_copy); - cbm_rmdir(cache); + cbm_mcp_server_free(srv); /* closes the store; header change is persisted */ + + long after_len = 0; + unsigned char *after = roq_read_file_bytes(db_path, &after_len); + + int identical = (before && after && before_len == after_len && + memcmp(before, after, (size_t)before_len) == 0); + + if (resp) { + free(resp); + } + free(before); + free(after); + cbm_unlink(db_path); + cbm_unlink(wal_path); + cbm_unlink(shm_path); + cbm_rmdir(tmp_cache); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + + ASSERT_TRUE(query_ok); /* read path ran and returned the node */ + ASSERT_FALSE(query_failed); /* not the "project not found" path */ + ASSERT_TRUE(identical); /* RED on buggy code: WAL pragma rewrote header */ + ASSERT_FALSE(wal_while_open); /* RED on buggy code: RW+WAL open spawned -wal */ PASS(); } -TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr) { - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-dot-adr-test-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-index-dot-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); +/* ── (b) READ-ONLY FILESYSTEM ───────────────────────────────────────── + * + * readonly_query_succeeds_on_readonly_fs + * + * Create a real project DB (left in WAL journal mode, as the indexer + * writes it), then chmod the CONTAINING DIRECTORY to 0555 (read-only) to + * simulate a read-only mount / immutable media, then run search_graph. + * + * Note on why the directory (not just the file) must be read-only: SQLite's + * unix VFS auto-downgrades a failed O_RDWR main-db open to O_RDONLY, so a + * 0444 *file* alone does NOT surface the bug — the connection silently + * becomes read-only and, with a writable dir, still creates the WAL -shm + * and reads. The genuine read-only-FS symptom is the WAL write-pragma + * (journal_mode=WAL) being unable to create the -shm/-wal sidecars in a + * read-only directory. + * + * WHY RED on unfixed code: + * cbm_store_open_path_query() runs configure_pragmas(.., false) which + * executes `PRAGMA journal_mode = WAL`. In a read-only directory the WAL + * wal-index (-shm) cannot be created, so the pragma errors -> + * configure_pragmas fails -> the open returns NULL -> resolve_store() + * returns NULL -> the handler emits "project not found or not indexed". + * + * GREEN on fixed code: + * the READONLY open skips the WAL write-pragma; the plain READONLY open + * of a WAL-mode DB in a read-only dir still needs -shm, so it fails and + * the immutable-URI fallback (file:..?immutable=1) reads the main DB + * file directly and the query returns the node. (This is the test that + * exercises the immutable fallback path.) + * ─────────────────────────────────────────────────────────────────── */ +TEST(readonly_query_succeeds_on_readonly_fs) { + char tmp_cache[512]; + snprintf(tmp_cache, sizeof(tmp_cache), "%s/cbm_roq_b_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(tmp_cache)) { + ASSERT_NOT_NULL(NULL); /* setup failure */ } - const char *saved = getenv("CBM_CACHE_DIR"); char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_CACHE_DIR", tmp_cache, 1); - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return helper()\n\ndef helper():\n return 1\n", fp); - fclose(fp); + char db_path[700]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, ROQ_PROJECT); + char wal_path[730]; + char shm_path[730]; + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); - char old_cwd[CBM_SZ_4K]; - ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + /* Build the DB in its natural WAL journal mode and ensure it is cleanly + * checkpointed (no -wal frames) so the immutable fallback can read all + * data from the main file. */ + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, ROQ_PROJECT, "/tmp/roq"), CBM_STORE_OK); + cbm_node_t node = {.project = ROQ_PROJECT, + .label = "Function", + .name = "ReadOnlyProbe", + .qualified_name = "roq.mod.ReadOnlyProbe", + .file_path = "mod.c"}; + ASSERT_TRUE(cbm_store_upsert_node(setup, &node) > 0); + (void)cbm_store_checkpoint(setup); /* fold WAL frames into the main file */ + cbm_store_close(setup); /* clean close removes -wal/-shm */ - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); + /* Make the containing directory read-only (simulate a read-only mount). + * SQLite can still traverse + read files, but cannot create -shm/-wal. */ + ASSERT_EQ(chmod(tmp_cache, 0555), 0); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + char args[512]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*ReadOnlyProbe.*\"}", + ROQ_PROJECT); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", args); - ASSERT_EQ(cbm_chdir(tmp_dir), 0); - char *resp = - cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); - ASSERT_EQ(cbm_chdir(old_cwd), 0); - ASSERT_NOT_NULL(resp); - if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + int query_ok = (resp && strstr(resp, "ReadOnlyProbe") != NULL); + int query_failed = (resp && (strstr(resp, "not found") || strstr(resp, "not indexed"))); + + if (resp) { free(resp); - cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - PASS(); } - ASSERT_NOT_NULL(strstr(resp, project)); - ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\"")); - free(resp); - - char update_args[2048]; - snprintf(update_args, sizeof(update_args), - "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" - "Dot-path ADR marker.\\n\"}", - project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); - free(resp); - - ASSERT_EQ(cbm_chdir(tmp_dir), 0); - resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); - ASSERT_EQ(cbm_chdir(old_cwd), 0); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - ASSERT_NOT_NULL(strstr(resp, project)); - ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); - ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\"")); - free(resp); - - char get_args[512]; - snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Dot-path ADR marker.")); - ASSERT_NULL(strstr(resp, "no_adr")); - free(resp); - cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - PASS(); -} -TEST(tool_manage_adr_not_found_rich_error) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-adr-missing-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); + /* Restore write permission on the dir BEFORE unlink (cannot remove dir + * entries while the directory is read-only). */ + chmod(tmp_cache, 0755); + cbm_unlink(db_path); + cbm_unlink(wal_path); + cbm_unlink(shm_path); + cbm_rmdir(tmp_cache); + if (saved_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + free(saved_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - - char *resp = cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"cbm-no-such-project-zzz\",\"mode\":\"get\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "or not indexed")); - ASSERT_NOT_NULL(strstr(resp, "hint")); - free(resp); - - cbm_mcp_server_free(srv); - restore_cache_dir(saved_copy); - free(saved_copy); - cbm_rmdir(cache); + ASSERT_FALSE(query_failed); /* RED on buggy code: WAL pragma fails on RO dir */ + ASSERT_TRUE(query_ok); /* RED on buggy code: no node returned */ PASS(); } -TEST(tool_manage_adr_get_accepts_abs_path) { - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-abspath-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-adr-abspath-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); - } +#undef ROQ_PROJECT - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); +/* ══════════════════════════════════════════════════════════════════ + * #823 — CLI/supervised index_repository must preserve name override + * ══════════════════════════════════════════════════════════════════ */ - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return 'ok'\n", fp); - fclose(fp); +enum { + IDX823_OK = 0, + IDX823_NO_SERVER = 61, + IDX823_NO_RESULT = 62, + IDX823_NOT_INDEXED = 63, + IDX823_RESPONSE_NAME_MISSING = 64, + IDX823_LIST_NAME_MISSING = 65, + IDX823_SEARCH_FAILED = 66, +}; - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); +#ifndef _WIN32 /* helper used only by the POSIX fork harness below */ +static int idx823_supervised_name_override_check(const char *repo_dir, const char *custom_name) { + /* Match the real CLI/MCP server state: a marked host with the supervisor + * enabled. The worker receives the same args JSON the CLI forwards. */ + cbm_index_supervisor_mark_host(); + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); + cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); + if (!srv) { + return IDX823_NO_SERVER; + } char args[1024]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\",\"name\":\"%s\"}", + repo_dir, custom_name); char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + int code = IDX823_OK; + if (!resp) { + code = IDX823_NO_RESULT; + } else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + code = IDX823_NOT_INDEXED; + } else { + char expected[256]; + snprintf(expected, sizeof(expected), "\"project\":\"%s\"", custom_name); + if (!response_contains_json_fragment(resp, expected)) { + code = IDX823_RESPONSE_NAME_MISSING; + } + } free(resp); - char update_args[2048]; - snprintf(update_args, sizeof(update_args), - "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" - "Abs-path normalization test.\\n\"}", - project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); - free(resp); + if (code == IDX823_OK) { + char *projects = cbm_mcp_handle_tool(srv, "list_projects", "{}"); + char expected[256]; + snprintf(expected, sizeof(expected), "\"name\":\"%s\"", custom_name); + if (!projects || !response_contains_json_fragment(projects, expected)) { + code = IDX823_LIST_NAME_MISSING; + } + free(projects); + } - char get_args[512]; - snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", tmp_dir); - resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Abs-path normalization test.")); - ASSERT_NULL(strstr(resp, "or not indexed")); - free(resp); + if (code == IDX823_OK) { + char q[512]; + snprintf(q, sizeof(q), + "{\"project\":\"%s\",\"name_pattern\":\"idx823_fn\",\"label\":\"Function\"}", + custom_name); + char *sr = cbm_mcp_handle_tool(srv, "search_graph", q); + if (!sr || !strstr(sr, "idx823_fn")) { + code = IDX823_SEARCH_FAILED; + } + free(sr); + } cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - PASS(); + return code; } +#endif -TEST(tool_manage_adr_get_accepts_symlink_path) { +TEST(index_repository_cli_name_override_issue823) { #ifdef _WIN32 - PASS(); + SKIP_PLATFORM("POSIX fork harness required to isolate supervisor host mark"); #else char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-realpath-XXXXXX"); + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx823-repo-XXXXXX"); if (!cbm_mkdtemp(tmp_dir)) { - PASS(); + FAIL("cbm_mkdtemp repo failed"); } char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-adr-realpath-cache-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-idx823-cache-XXXXXX"); if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); - } - - char link_path[320]; - snprintf(link_path, sizeof(link_path), "%s-link", tmp_dir); - (void)unlink(link_path); - if (symlink(tmp_dir, link_path) != 0) { - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - PASS(); + th_rmtree(tmp_dir); + FAIL("cbm_mkdtemp cache failed"); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); char src_path[512]; snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return 'ok'\n", fp); - fclose(fp); - - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); - - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); + ASSERT_EQ(th_write_file(src_path, "def idx823_fn():\n return 823\n"), 0); - char args[1024]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", link_path); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - ASSERT_NOT_NULL(strstr(resp, project)); - free(resp); + const char *custom_name = "issue823-custom-project"; + int code = -1; + bool signalled = false; + int sig = 0; - char update_args[2048]; - snprintf(update_args, sizeof(update_args), - "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" - "Symlink-path normalization test.\\n\"}", - project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); - free(resp); + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(60); + _exit(idx823_supervised_name_override_check(tmp_dir, custom_name)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } - char get_args[512]; - snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", link_path); - resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Symlink-path normalization test.")); - ASSERT_NULL(strstr(resp, "or not indexed")); - ASSERT_NULL(strstr(resp, "no_adr")); - free(resp); + char *path_project = cbm_project_name_from_path(tmp_dir); + cleanup_project_db(cache, custom_name); + cleanup_project_db(cache, path_project); + free(path_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + th_rmtree(cache); + th_rmtree(tmp_dir); - cbm_mcp_server_free(srv); - cleanup_project_db(cache, project); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); - unlink(link_path); - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); + if (signalled) { + printf(" child killed by signal %d (alarm => worker hang)\n", sig); + } else if (code != IDX823_OK) { + printf(" child exit code %d (64=response name, 65=list name, 66=search)\n", code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDX823_OK); PASS(); #endif } -TEST(tool_detect_changes_not_found_rich_error) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-detect-missing-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); - } +/* ══════════════════════════════════════════════════════════════════ + * #845 — supervisor gate must not wrap embedders of cbm_mcp_handle_tool + * ══════════════════════════════════════════════════════════════════ */ - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); +/* Child-side check: index a tiny fixture and verify it ran IN-PROCESS. + * Distinct exit codes so the parent can report the exact failure mode. */ +enum { + IDX845_OK = 0, + IDX845_SPAWNED = 41, /* a worker subprocess was spawned — the #845 bug */ + IDX845_NO_RESULT = 42, /* handle_tool returned NULL */ + IDX845_NOT_INDEXED = 43, /* response lacks status=indexed */ +}; + +static int idx845_index_inprocess_check(const char *repo_dir) { + int spawns_before = cbm_index_supervisor_spawn_count(); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); + if (!srv) { + return IDX845_NO_RESULT; + } + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - char *resp = - cbm_mcp_handle_tool(srv, "detect_changes", "{\"project\":\"cbm-no-such-project-zzz\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "or not indexed")); - ASSERT_NOT_NULL(strstr(resp, "hint")); + int code = IDX845_OK; + if (cbm_index_supervisor_spawn_count() != spawns_before) { + code = IDX845_SPAWNED; + } else if (!resp) { + code = IDX845_NO_RESULT; + } else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + code = IDX845_NOT_INDEXED; + } free(resp); - cbm_mcp_server_free(srv); - restore_cache_dir(saved_copy); - free(saved_copy); - cbm_rmdir(cache); - PASS(); + return code; } -/* detect_changes owns shell output through regular temporary files. An error - * after opening that file must use fclose + unlink. The command hook then - * rejects merge-base only when it reaches the contained subprocess helper, so - * a raw popen regression bypasses the hook and fails this test. */ -TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) { - char cache[512]; - (void)snprintf(cache, sizeof(cache), "%s/cbm-detect-contained-XXXXXX", cbm_tmpdir()); - bool cache_created = cbm_mkdtemp(cache) != NULL; - char repo[CBM_SZ_4K]; - (void)snprintf(repo, sizeof(repo), "%s/cbm-detect-repo-XXXXXX", cbm_tmpdir()); - bool repo_created = cbm_mkdtemp(repo) != NULL; - char empty_template[CBM_SZ_4K]; - char empty_hooks[CBM_SZ_4K]; - char template_argument[CBM_SZ_4K]; - char hooks_config[CBM_SZ_4K]; - char hostile_template[CBM_SZ_4K]; - char hostile_template_hooks[CBM_SZ_4K]; - char hostile_hooks[CBM_SZ_4K]; - char hostile_hook[CBM_SZ_4K]; - char hostile_config[CBM_SZ_4K]; - int template_length = - snprintf(empty_template, sizeof(empty_template), "%s/.cbm-empty-template", repo); - int hooks_length = snprintf(empty_hooks, sizeof(empty_hooks), "%s/.cbm-empty-hooks", repo); - int template_argument_length = - snprintf(template_argument, sizeof(template_argument), "--template=%s", empty_template); - int hooks_config_length = - snprintf(hooks_config, sizeof(hooks_config), "core.hooksPath=%s", empty_hooks); - int hostile_template_length = - snprintf(hostile_template, sizeof(hostile_template), "%s/.cbm-hostile-template", repo); - int hostile_template_hooks_length = snprintf( - hostile_template_hooks, sizeof(hostile_template_hooks), "%s/hooks", hostile_template); - int hostile_hooks_length = - snprintf(hostile_hooks, sizeof(hostile_hooks), "%s/.cbm-hostile-hooks", repo); - int hostile_hook_length = - snprintf(hostile_hook, sizeof(hostile_hook), "%s/pre-commit", hostile_hooks); - int hostile_config_length = - snprintf(hostile_config, sizeof(hostile_config), "%s/.cbm-hostile-gitconfig", repo); - bool git_isolation_ready = - repo_created && template_length > 0 && (size_t)template_length < sizeof(empty_template) && - hooks_length > 0 && (size_t)hooks_length < sizeof(empty_hooks) && - template_argument_length > 0 && - (size_t)template_argument_length < sizeof(template_argument) && hooks_config_length > 0 && - (size_t)hooks_config_length < sizeof(hooks_config) && cbm_mkdir(empty_template) == 0 && - cbm_mkdir(empty_hooks) == 0; - bool hostile_paths_ready = - git_isolation_ready && hostile_template_length > 0 && - (size_t)hostile_template_length < sizeof(hostile_template) && - hostile_template_hooks_length > 0 && - (size_t)hostile_template_hooks_length < sizeof(hostile_template_hooks) && - hostile_hooks_length > 0 && (size_t)hostile_hooks_length < sizeof(hostile_hooks) && - hostile_hook_length > 0 && (size_t)hostile_hook_length < sizeof(hostile_hook) && - hostile_config_length > 0 && (size_t)hostile_config_length < sizeof(hostile_config) && - cbm_mkdir(hostile_template) == 0 && cbm_mkdir(hostile_template_hooks) == 0 && - cbm_mkdir(hostile_hooks) == 0; - FILE *hostile_hook_file = hostile_paths_ready ? cbm_fopen(hostile_hook, "wb") : NULL; - bool hostile_hook_ready = false; - if (hostile_hook_file) { - bool hook_written = fputs("#!/bin/sh\nexit 91\n", hostile_hook_file) >= 0; - bool hook_closed = fclose(hostile_hook_file) == 0; - hostile_hook_ready = hook_written && hook_closed && chmod(hostile_hook, 0700) == 0; - } - FILE *hostile_config_file = hostile_hook_ready ? cbm_fopen(hostile_config, "wb") : NULL; - bool hostile_config_ready = false; - if (hostile_config_file) { - bool config_written = - fprintf(hostile_config_file, "[init]\n\ttemplateDir = %s\n[core]\n\thooksPath = %s\n", - hostile_template, hostile_hooks) > 0; - bool config_closed = fclose(hostile_config_file) == 0; - hostile_config_ready = config_written && config_closed; - } - mcp_test_env_backup_t ambient_git = {.name = "GIT_CONFIG_GLOBAL"}; - const char *ambient_git_value = getenv(ambient_git.name); - ambient_git.present = ambient_git_value != NULL; - ambient_git.value = ambient_git_value ? strdup(ambient_git_value) : NULL; - bool ambient_git_saved = !ambient_git_value || ambient_git.value; - bool hostile_environment_ready = hostile_config_ready && ambient_git_saved && - cbm_setenv("GIT_CONFIG_GLOBAL", hostile_config, 1) == 0; - const char *const init_args[] = {"init", "-q", template_argument, NULL}; - const char *const commit_args[] = { - "-c", "user.name=cbm-test", - "-c", "user.email=cbm-test@example.invalid", - "-c", "commit.gpgsign=false", - "-c", hooks_config, - "commit", "--allow-empty", - "-q", "-m", - "fixture", NULL, - }; - bool repo_ready = hostile_environment_ready && mcp_test_git(repo, init_args) == 0 && - mcp_test_git(repo, commit_args) == 0; - if (ambient_git_saved) { - mcp_test_restore_env(&ambient_git, 1U); +TEST(index_supervisor_gate_requires_marked_host_issue845) { + /* #845: index_repository via cbm_mcp_handle_tool from an EMBEDDER (this test + * binary) must index IN-PROCESS even with CBM_INDEX_SUPERVISOR unset. The + * supervisor gate may only wrap a process that called + * cbm_index_supervisor_mark_host() — i.e. the real binary's main(). Before + * the fix, should_wrap() was true for ANY embedder: the gate resolved the + * CURRENT binary (this test runner!) and spawned + * ' cli --index-worker index_repository …', which a test binary + * interprets as suite-filter args → it re-runs test suites in the child → + * recursive spawn chains (observed 11-min hangs; kernel VM-map load during + * the 2026-07-04 host panics). + * + * POSIX: run the call in a forked child under alarm(20) so the pre-fix + * recursive behaviour cannot hang the runner; the child reports via exit + * code. Windows: no fork — run in-process (safe once the gate is fixed; the + * pre-fix redness is demonstrated on POSIX). */ + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx845-repo-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-idx845-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); } + const char *saved_cache = getenv("CBM_CACHE_DIR"); char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - bool environment_ready = cache_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; - - const char *project = "detect-contained-project"; - cbm_mcp_server_t *srv = environment_ready && repo_ready ? cbm_mcp_server_new(NULL) : NULL; - bool server_ready = srv != NULL; - cbm_store_t *store = srv ? cbm_mcp_server_store(srv) : NULL; - bool project_ready = store && cbm_store_upsert_project(store, project, repo) == CBM_STORE_OK; - mcp_command_hook_probe_t command_probe = {.reject_merge_base = true}; - if (project_ready) { - cbm_mcp_server_set_project(srv, project); - cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe); - } + cbm_setenv("CBM_CACHE_DIR", cache, 1); - char *invalid_response = - project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", - "{\"project\":\"detect-contained-project\"," - "\"base_branch\":\"HEAD\",\"scope\":\"files\"," - "\"direction\":\"sideways\"}") - : NULL; - bool invalid_rejected = invalid_response && strstr(invalid_response, "invalid direction"); - char logs[640]; - (void)snprintf(logs, sizeof(logs), "%s/logs", cache); - int artifacts_after_error = - invalid_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1; + /* The point of the guard: NO kill switch. The gate itself must keep an + * unmarked host in-process. Save + restore the ambient value. */ + const char *saved_sv = getenv("CBM_INDEX_SUPERVISOR"); + char *saved_sv_copy = saved_sv ? strdup(saved_sv) : NULL; + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - char *rejected_response = - project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", - "{\"project\":\"detect-contained-project\"," - "\"base_branch\":\"HEAD\",\"scope\":\"files\"}") - : NULL; - bool containment_rejected = - rejected_response && strstr(rejected_response, "contained command could not complete"); - int artifacts_after_rejection = - rejected_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1; + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); - command_probe.reject_merge_base = false; - char *success_response = - project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", - "{\"project\":\"detect-contained-project\"," - "\"base_branch\":\"HEAD\",\"scope\":\"files\"}") - : NULL; - bool merge_base_reported = success_response && strstr(success_response, "merge_base"); - int artifacts_after_success = - success_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1; + int code = -1; + bool signalled = false; + int sig = 0; +#ifdef _WIN32 + code = idx845_index_inprocess_check(tmp_dir); +#else + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(20); /* pre-fix spawn chain must die here, not hang the runner */ + _exit(idx845_index_inprocess_check(tmp_dir)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } +#endif - free(invalid_response); - free(rejected_response); - free(success_response); - cbm_mcp_server_free(srv); + /* Restore env BEFORE asserting so a red run doesn't leak state. */ + if (saved_sv_copy) { + cbm_setenv("CBM_INDEX_SUPERVISOR", saved_sv_copy, 1); + free(saved_sv_copy); + } else { + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + } + char *project = cbm_project_name_from_path(tmp_dir); + cleanup_project_db(cache, project); + free(project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - bool cleaned = !cache_created || th_rmtree(cache) == 0; - /* Git for Windows makes loose objects read-only; the shared test cleanup - * must still remove the entire self-contained fixture. */ - bool repo_cleaned = !repo_created || th_rmtree(repo) == 0; + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); - ASSERT_TRUE(cache_created); - ASSERT_TRUE(repo_created); - ASSERT_TRUE(repo_ready); - ASSERT_TRUE(environment_ready); - ASSERT_TRUE(server_ready); - ASSERT_TRUE(project_ready); - ASSERT_TRUE(invalid_rejected); - ASSERT_EQ(artifacts_after_error, 0); - ASSERT_TRUE(containment_rejected); - ASSERT_EQ(artifacts_after_rejection, 0); - ASSERT_TRUE(merge_base_reported); - ASSERT_EQ(artifacts_after_success, 0); - ASSERT_EQ(command_probe.diff_calls, 3); - ASSERT_EQ(command_probe.merge_base_calls, 2); - ASSERT_TRUE(cleaned); - ASSERT_TRUE(repo_cleaned); + if (signalled) { + printf(" child killed by signal %d (alarm => recursive spawn chain hang)\n", sig); + } else if (code != IDX845_OK) { + printf(" child exit code %d (41=worker spawned, 42=no result, 43=not indexed)\n", code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDX845_OK); PASS(); } -/* Regression test for issue #1363: detect_changes seeded every definition in - * a changed file instead of just the ones whose line range overlaps the diff - * hunk. cbm_detect_node_in_hunks is the overlap primitive; this exercises it - * directly, independent of the git/subprocess/index plumbing around it. */ -TEST(detect_changes_node_in_hunks_overlap_issue1363) { - cbm_changed_hunk_t hunks[2] = { - {.path = "pkg/mod.py", .start_line = 10, .end_line = 12}, - {.path = "pkg/other.py", .start_line = 1, .end_line = 1}, - }; - - cbm_node_t inside = {.start_line = 8, .end_line = 15}; - ASSERT(cbm_detect_node_in_hunks(&inside, hunks, 2, "pkg/mod.py")); - - cbm_node_t exact = {.start_line = 10, .end_line = 12}; - ASSERT(cbm_detect_node_in_hunks(&exact, hunks, 2, "pkg/mod.py")); - - cbm_node_t touches_edge = {.start_line = 12, .end_line = 20}; - ASSERT(cbm_detect_node_in_hunks(&touches_edge, hunks, 2, "pkg/mod.py")); - - cbm_node_t before = {.start_line = 1, .end_line = 9}; - ASSERT(!cbm_detect_node_in_hunks(&before, hunks, 2, "pkg/mod.py")); +/* A watcher publishes a new database generation from a worker process while the + * long-lived MCP request thread may still hold a read-only handle to the old, + * unlinked inode. Publication notification must be deferred: the watcher only + * marks the handle stale, and the next request closes and reopens it on its + * owning thread. */ +TEST(watcher_publication_reopens_cached_store_generation) { + const char *cache = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache); + const char *project = "synthetic-generation-project"; + char live_path[CBM_PATH_MAX]; + char next_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(live_path, sizeof(live_path), cache, project), CBM_STORE_OK); + snprintf(next_path, sizeof(next_path), "%s/next-generation.db", cache); + ASSERT_TRUE(mcp_create_generation_db(live_path, project, "Function", "BeforePublication")); - cbm_node_t after = {.start_line = 13, .end_line = 20}; - ASSERT(!cbm_detect_node_in_hunks(&after, hunks, 2, "pkg/mod.py")); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *before = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"synthetic-generation-project\"," + "\"name_pattern\":\"BeforePublication\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(before); + ASSERT_NOT_NULL(strstr(before, "BeforePublication")); + free(before); - /* Same line range, different file — must not match. */ - cbm_node_t wrong_file = {.start_line = 10, .end_line = 12}; - ASSERT(!cbm_detect_node_in_hunks(&wrong_file, hunks, 2, "pkg/unrelated.py")); + ASSERT_TRUE(mcp_create_generation_db(next_path, project, "Function", "AfterPublication")); + cbm_remove_db_sidecars(live_path); + ASSERT_EQ(cbm_replace_file(next_path, live_path), 0); + + cbm_mcp_server_notify_index_published(srv); + char *after = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"synthetic-generation-project\"," + "\"name_pattern\":\"AfterPublication\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(after); + ASSERT_NOT_NULL(strstr(after, "AfterPublication")); + ASSERT_NULL(strstr(after, "BeforePublication")); + free(after); + cbm_mcp_server_free(srv); + mcp_unlink_db_sidecars(live_path); + mcp_unlink_db_sidecars(next_path); PASS(); } -/* End-to-end regression test for issue #1363: a same-line-count edit inside - * one function must seed only that function, not every definition in the - * file. A flat file with two independent top-level functions (no enclosing - * class) makes this unambiguous — before the fix, editing foo() also seeded - * bar() because seeding was scoped to the whole changed file. */ -TEST(detect_changes_seeds_only_touched_symbol_issue1363) { - char repo[512]; - snprintf(repo, sizeof(repo), "%s/cbm-detect-seed-scope-XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(repo)) { - FAIL("cbm_mkdtemp failed"); - } - - char src[600]; - snprintf(src, sizeof(src), "%s/mod.py", repo); - ASSERT_EQ(th_write_file(src, "def foo():\n" - " x = 1\n" - " return x\n" - "\n" - "\n" - "def bar():\n" - " y = 2\n" - " return y\n"), - 0); - - /* `git -C` with double quotes, not `cd '' &&`: single quotes are not - * quoting characters for cmd.exe, and identity/branch/signing come from -c - * so the fixture does not depend on the machine's global git config. The - * assertions below read `base: main`, so pin init.defaultBranch. */ -#define DC1363_GITCFG \ - "-c user.name=t -c user.email=t@t.io -c init.defaultBranch=main -c commit.gpgsign=false" - char cmd[1200]; - const char *steps[] = {"init -q", "add -A", "commit -q -m init"}; - for (size_t s = 0; s < sizeof(steps) / sizeof(steps[0]); s++) { - snprintf(cmd, sizeof(cmd), "git -C \"%s\" " DC1363_GITCFG " %s", repo, steps[s]); - if (system(cmd) != 0) { - th_rmtree(repo); - FAIL("git fixture setup failed"); - } - } -#undef DC1363_GITCFG +/* A separate CLI or MCP process cannot call notify_index_published() on this + * server. The next request must therefore notice that atomic publication + * replaced the cache path and reopen its read-only handle instead of serving + * the old, unlinked SQLite generation indefinitely. */ +TEST(external_process_publication_reopens_cached_store_generation) { + const char *cache = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache); + const char *project = "external-generation-project"; + char live_path[CBM_PATH_MAX]; + char next_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(live_path, sizeof(live_path), cache, project), CBM_STORE_OK); + snprintf(next_path, sizeof(next_path), "%s/external-next-generation.db", cache); + ASSERT_TRUE(mcp_create_generation_db(live_path, project, "BeforeExternalLabel", + "BeforeExternalPublication")); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char idx_args[700]; - snprintf(idx_args, sizeof(idx_args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", repo); - char *idx_resp = cbm_mcp_handle_tool(srv, "index_repository", idx_args); - ASSERT_NOT_NULL(idx_resp); - free(idx_resp); + ASSERT_NOT_NULL(srv); + char *before = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"external-generation-project\"," + "\"name_pattern\":\"BeforeExternalPublication\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(before); + ASSERT_NOT_NULL(strstr(before, "BeforeExternalPublication")); + free(before); - /* Same-line-count in-place edit inside foo() only; bar() is untouched. */ - ASSERT_EQ(th_write_file(src, "def foo():\n" - " x = 11\n" - " return x\n" - "\n" - "\n" - "def bar():\n" - " y = 2\n" - " return y\n"), - 0); + char *before_list = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{}}"); + ASSERT_NOT_NULL(before_list); + ASSERT_NOT_NULL(strstr(before_list, "BeforeExternalLabel")); + free(before_list); - char *project = cbm_project_name_from_path(repo); - ASSERT_NOT_NULL(project); - char dc_args[700]; - snprintf(dc_args, sizeof(dc_args), "{\"project\":\"%s\",\"depth\":1}", project); - char *dc_resp = cbm_mcp_handle_tool(srv, "detect_changes", dc_args); - ASSERT_NOT_NULL(dc_resp); - /* cbm_mcp_handle_tool wraps the tree text in a JSON string, so a literal - * newline in the source becomes the two-character `\n` escape sequence - * in dc_resp's actual bytes — match that, not a real newline. */ - ASSERT_NOT_NULL(strstr(dc_resp, "seed_symbols: 1\\n")); - ASSERT_NULL(strstr(dc_resp, "bar")); + ASSERT_TRUE(mcp_create_generation_db(next_path, project, "AfterExternalLabel", + "AfterExternalPublication")); + cbm_remove_db_sidecars(live_path); + ASSERT_EQ(cbm_replace_file(next_path, live_path), 0); + + /* Deliberately no cbm_mcp_server_notify_index_published(): a sibling + * process has no access to this server's in-memory notification flag. */ + char *after_list = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":202,\"method\":\"tools/list\",\"params\":{}}"); + ASSERT_NOT_NULL(after_list); + ASSERT_NOT_NULL(strstr(after_list, "AfterExternalLabel")); + ASSERT_NULL(strstr(after_list, "BeforeExternalLabel")); + free(after_list); + + char *after = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"external-generation-project\"," + "\"name_pattern\":\"AfterExternalPublication\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(after); + ASSERT_NOT_NULL(strstr(after, "AfterExternalPublication")); + ASSERT_NULL(strstr(after, "BeforeExternalPublication")); + free(after); - free(dc_resp); - free(project); cbm_mcp_server_free(srv); - th_rmtree(repo); + mcp_unlink_db_sidecars(live_path); + mcp_unlink_db_sidecars(next_path); PASS(); } -/* Recall guard for the zero-overlap case (#1363 review): an import-only edit - * changes lines that lie outside every definition's range. Scoping alone would - * drop the file from the seed set — worse recall than the whole-file behavior - * being replaced — so detect_collect_seeds falls back to whole-file seeding - * when a changed file has hunks but no definition overlapping any of them. */ -TEST(detect_changes_zero_overlap_falls_back_issue1363) { - char repo[512]; - snprintf(repo, sizeof(repo), "%s/cbm-detect-zero-overlap-XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(repo)) { - FAIL("cbm_mkdtemp failed"); - } - - char src[600]; - snprintf(src, sizeof(src), "%s/mod.py", repo); - /* Import on line 1 sits above both definitions. */ - ASSERT_EQ(th_write_file(src, "import os\n" - "\n" - "\n" - "def foo():\n" - " return 1\n" - "\n" - "\n" - "def bar():\n" - " return 2\n"), - 0); +/* ══════════════════════════════════════════════════════════════════ + * #832 — background auto-index + watcher re-index must run in the + * supervised worker SUBPROCESS (RSS isolation) + * ══════════════════════════════════════════════════════════════════ */ -#define DC1363B_GITCFG \ - "-c user.name=t -c user.email=t@t.io -c init.defaultBranch=main -c commit.gpgsign=false" - char cmd[1200]; - const char *steps[] = {"init -q", "add -A", "commit -q -m init"}; - for (size_t s = 0; s < sizeof(steps) / sizeof(steps[0]); s++) { - snprintf(cmd, sizeof(cmd), "git -C \"%s\" " DC1363B_GITCFG " %s", repo, steps[s]); - if (system(cmd) != 0) { - th_rmtree(repo); - FAIL("git fixture setup failed"); - } - } -#undef DC1363B_GITCFG +/* The long-lived server ran the full index pipeline in-process on two background + * paths (session auto-index in mcp.c, watcher re-index in main.c). Worker-thread + * mimalloc heaps abandon pages at thread exit and mimalloc v3 + * (page_reclaim_on_free=0) does not reclaim them when the main thread later frees + * their blocks, so RSS ratchets across re-index cycles (#832). The fix routes both + * paths through cbm_mcp_index_run_supervised_path() — the SAME supervised worker + * subprocess the index_repository tool uses — so the child hands 100%% of its RSS + * back to the OS on exit. + * + * This guard proves the ROUTING: on a supervisor-marked host with the kill switch + * OFF, the shared entry the watcher/auto-index now call must (a) spawn a worker + * child (cbm_index_supervisor_spawn_count() increases) and (b) actually index the + * fixture (the worker child writes the Function node). RED on the unfixed + * in-process routing: it calls cbm_pipeline_run directly, so spawn_count is + * unchanged → IDX832_NO_SPAWN. */ +enum { + IDX832_OK = 0, + IDX832_NO_SPAWN = 51, /* spawn_count unchanged — routed in-process (RED) */ + IDX832_NULL_RESP = 52, /* supervised entry degraded to NULL */ + IDX832_NOT_INDEXED = 53, /* response/store lacks the indexed Function node */ + IDX832_SERVER_FAIL = 54, + IDX832_WORKER_CONTEXT = 55, /* internal response leaked externally-owned _context */ +}; - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char idx_args[700]; - snprintf(idx_args, sizeof(idx_args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", repo); - char *idx_resp = cbm_mcp_handle_tool(srv, "index_repository", idx_args); - ASSERT_NOT_NULL(idx_resp); - free(idx_resp); +#ifndef _WIN32 /* helper used only by the POSIX fork harness below */ +static int idx832_supervised_route_check(const char *repo_dir) { + /* Become a supervisor host with the kill switch OFF — exactly the real MCP + * server's state. Done in the FORKED CHILD only (see the harness) so the + * parent test-runner's process-wide host mark stays clear and the #845 + * unmarked-embedder guard is unaffected. Bound the recovery loop + worker + * quiet-timeout so a stuck child cannot run long under the fork+alarm net. */ + cbm_index_supervisor_mark_host(); + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); + cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); - /* Edit ONLY the import line — outside every definition's line range. */ - ASSERT_EQ(th_write_file(src, "import os, sys\n" - "\n" - "\n" - "def foo():\n" - " return 1\n" - "\n" - "\n" - "def bar():\n" - " return 2\n"), - 0); + int spawns_before = cbm_index_supervisor_spawn_count(); + char *resp = cbm_mcp_index_run_supervised_path(NULL, repo_dir); + int spawns_after = cbm_index_supervisor_spawn_count(); - char *project = cbm_project_name_from_path(repo); - ASSERT_NOT_NULL(project); - char dc_args[700]; - snprintf(dc_args, sizeof(dc_args), "{\"project\":\"%s\",\"depth\":1}", project); - char *dc_resp = cbm_mcp_handle_tool(srv, "detect_changes", dc_args); - ASSERT_NOT_NULL(dc_resp); - /* Both definitions must survive: zero overlaps means no scoping for this - * file, not an empty seed set. */ - ASSERT_NOT_NULL(strstr(dc_resp, "seed_symbols: 2\\n")); + if (spawns_after == spawns_before) { + free(resp); + return IDX832_NO_SPAWN; /* the discriminating assertion: RED in-process */ + } + if (!resp) { + return IDX832_NULL_RESP; + } + bool indexed = response_contains_json_fragment(resp, "\"status\":\"indexed\""); + bool leaked_worker_context = strstr(resp, "\\\"_context\\\":") != NULL; + free(resp); + if (!indexed) { + return IDX832_NOT_INDEXED; + } + if (leaked_worker_context) { + return IDX832_WORKER_CONTEXT; + } - free(dc_resp); - free(project); + /* Store-level proof the worker child did real work: the Function node it wrote + * must be queryable from a fresh server reading the DB the child produced. */ + char *project = cbm_project_name_from_path(repo_dir); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) { + free(project); + return IDX832_SERVER_FAIL; + } + int code = IDX832_OK; + if (project) { + char q[512]; + snprintf(q, sizeof(q), + "{\"project\":\"%s\",\"name_pattern\":\"idx832_fn\",\"label\":\"Function\"}", + project); + char *sr = cbm_mcp_handle_tool(srv, "search_graph", q); + if (!sr || !strstr(sr, "idx832_fn")) { + code = IDX832_NOT_INDEXED; + } + free(sr); + } cbm_mcp_server_free(srv); - th_rmtree(repo); - PASS(); + free(project); + return code; } +#endif /* !_WIN32 */ -TEST(tool_ingest_traces_basic) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +TEST(index_bg_paths_route_through_supervisor_issue832) { +#ifdef _WIN32 + /* The guard marks the process as a supervisor host, which cannot be undone. + * POSIX isolates that in a forked child; without fork we would pollute the + * shared test-runner (breaking the #845 unmarked-embedder guard). The routing + * logic is platform-independent and covered on POSIX CI; Windows containment + * is covered by the end-to-end crash-containment test. */ + SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)"); +#else + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx832-repo-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-idx832-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":37,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"ingest_traces\"," - "\"arguments\":{\"traces\":[{\"caller\":\"a\",\"callee\":\"b\"}]}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "accepted")); - ASSERT_NOT_NULL(strstr(resp, "traces_received")); - free(resp); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */ - cbm_mcp_server_free(srv); - PASS(); -} + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def idx832_fn():\n return 'ok'\n", fp); + fclose(fp); -TEST(tool_ingest_traces_empty) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + int code = -1; + bool signalled = false; + int sig = 0; + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(60); /* a stuck worker dies here instead of hanging the runner */ + _exit(idx832_supervised_route_check(tmp_dir)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":38,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"ingest_traces\"," - "\"arguments\":{\"traces\":[]}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "accepted")); - free(resp); + char *project = cbm_project_name_from_path(tmp_dir); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); - cbm_mcp_server_free(srv); + if (signalled) { + printf(" child killed by signal %d (alarm => worker hang)\n", sig); + } else if (code != IDX832_OK) { + printf(" child exit code %d (51=no spawn/in-process=RED, 52=null resp, " + "53=not indexed, 54=server fail)\n", + code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDX832_OK); PASS(); +#endif } /* ══════════════════════════════════════════════════════════════════ - * IDLE STORE EVICTION + * Parallel-only crash recovery (ms-typescript cascade fix) * ══════════════════════════════════════════════════════════════════ */ -TEST(store_idle_eviction) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - cbm_mcp_server_set_project(srv, "test-evict"); - - /* Trigger resolve_store via a tool call to set store_last_used */ - char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); - free(resp); - - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); - - /* Evict with 0s timeout → should evict immediately */ - cbm_mcp_server_evict_idle(srv, 0); - ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv)); +/* The old recovery loop re-ran the worker SINGLE-THREADED to keep one exact + * crash marker. At scale that fell into the sequential crawl, was killed as + * a hang mid-pass, and the stale marker quarantined FOUR innocent + * ms-typescript fixtures, one 15-minute retry at a time. The reworked loop + * re-runs PARALLEL with a marker journal; a file is quarantined only when + * it is in-flight across two consecutive failed runs. + * + * This guard proves the CONTRACT: with an injected crasher among good + * files, the supervised index must (a) never spawn a single-threaded worker + * (cbm_index_supervisor_spawn_st_count stays 0 — RED on the old loop), + * (b) quarantine exactly the crasher, (c) leave the innocents indexed and + * NOT quarantined. */ +enum { + IDXPAR_OK = 0, + IDXPAR_ST_SPAWN = 61, /* single-threaded recovery spawn happened (RED) */ + IDXPAR_NULL_RESP = 62, /* supervised entry degraded to NULL */ + IDXPAR_NOT_INDEXED = 63, /* response lacks status indexed */ + IDXPAR_NO_QUARANTINE = 64, /* crasher missing from skipped[] */ + IDXPAR_INNOCENT_HIT = 65, /* a good file was quarantined/skipped */ + IDXPAR_GOOD_MISSING = 66, /* good file's Function absent from the store */ +}; - cbm_mcp_server_free(srv); - PASS(); -} +#ifndef _WIN32 +static int idxpar_recovery_check(const char *repo_dir) { + cbm_index_supervisor_mark_host(); + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + /* Rounds needed: fail+record, fail+quarantine, clean. Generous cap. */ + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "5", 1); + cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); + cbm_setenv("CBM_TEST_CRASH_ON", "idxpar_crasher", 1); -TEST(store_idle_no_eviction_within_timeout) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - cbm_mcp_server_set_project(srv, "test-evict"); + int st_before = cbm_index_supervisor_spawn_st_count(); + char *resp = cbm_mcp_index_run_supervised_path(NULL, repo_dir); + int st_after = cbm_index_supervisor_spawn_st_count(); + cbm_unsetenv("CBM_TEST_CRASH_ON"); - char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); + if (st_after != st_before) { + free(resp); + return IDXPAR_ST_SPAWN; /* discriminating assertion: RED on the old loop */ + } + if (!resp) { + return IDXPAR_NULL_RESP; + } + bool indexed = response_contains_json_fragment(resp, "\"status\":\"indexed\""); + bool crasher_skipped = strstr(resp, "idxpar_crasher.py") != NULL; + bool innocent_hit = + strstr(resp, "idxpar_good_a.py") != NULL || strstr(resp, "idxpar_good_b.py") != NULL; + if (!indexed) { + fprintf(stderr, " supervised recovery response: %.*s\n", CBM_SZ_4K, resp); + } free(resp); + if (!indexed) { + return IDXPAR_NOT_INDEXED; + } + if (!crasher_skipped) { + return IDXPAR_NO_QUARANTINE; + } + if (innocent_hit) { + return IDXPAR_INNOCENT_HIT; + } - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); - - /* Evict with large timeout → should NOT evict */ - cbm_mcp_server_evict_idle(srv, 99999); - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); - - cbm_mcp_server_free(srv); - PASS(); + /* Store proof: an innocent's Function node exists. */ + char *project = cbm_project_name_from_path(repo_dir); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + int code = IDXPAR_OK; + if (srv && project) { + char q[512]; + snprintf(q, sizeof(q), + "{\"project\":\"%s\",\"name_pattern\":\"idxpar_good_fn\",\"label\":\"Function\"}", + project); + char *sr = cbm_mcp_handle_tool(srv, "search_graph", q); + if (!sr || !strstr(sr, "idxpar_good_fn")) { + code = IDXPAR_GOOD_MISSING; + } + free(sr); + } + if (srv) { + cbm_mcp_server_free(srv); + } + free(project); + return code; } +#endif /* !_WIN32 */ -TEST(store_idle_evict_protects_initial_store) { - /* Evicting with NULL server should not crash */ - cbm_mcp_server_evict_idle(NULL, 0); - - /* Evicting server whose store was never accessed via a named project - * should NOT evict the initial in-memory store (store_last_used == 0). */ - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); - cbm_mcp_server_evict_idle(srv, 0); - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); +/* #773: SIGABRT (invalid free in ts_stack_delete via + * cbm_destroy_thread_parser) on the SECOND index_repository in one server + * process, once both repos take the PARALLEL path (~30+ files). The + * supervisor masks this on the default MCP path (fresh worker process per + * index); the in-process pipeline — CBM_INDEX_SUPERVISOR=0, and every + * embedded/test consumer — dies. Forked child so the abort cannot kill the + * runner; ASan legs print the exact bad free. */ +enum { + IDX773_OK = 0, + IDX773_FIRST_FAILED = 71, /* first index didn't return indexed */ + IDX773_SECOND_FAILED = 72, /* second index didn't return indexed */ +}; - cbm_mcp_server_free(srv); - PASS(); +#ifndef _WIN32 +static void idx773_write_py_repo(const char *dir, int files, int variant) { + for (int i = 0; i < files; i++) { + char path[CBM_SZ_512]; + snprintf(path, sizeof(path), "%s/mod_%d_%03d.py", dir, variant, i); + FILE *f = fopen(path, "w"); + if (!f) { + continue; + } + fprintf(f, + "class Handler%d:\n" + " def run(self, x):\n" + " return self.helper(x) + %d\n" + " def helper(self, x):\n" + " for i in range(10):\n" + " x += i\n" + " return x\n" + "\n" + "def main_%d(x):\n" + " return Handler%d().run(x)\n", + i, i, i, i); + fclose(f); + } } -TEST(store_idle_evict_access_resets_timer) { +static int idx773_double_index_check(const char *dir_a, const char *dir_b) { + cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - cbm_mcp_server_set_project(srv, "test-evict"); - - /* First access */ - char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); - free(resp); - - /* Second access (resets timer) */ - resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}"); - free(resp); - - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); - - /* With large timeout, store should survive */ - cbm_mcp_server_evict_idle(srv, 99999); - ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); - - /* With 0 timeout, store should be evicted */ - cbm_mcp_server_evict_idle(srv, 0); - ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv)); - + if (!srv) { + return IDX773_FIRST_FAILED; + } + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_a); + char *r1 = cbm_mcp_handle_tool(srv, "index_repository", args); + bool ok1 = r1 && strstr(r1, "indexed") != NULL; + free(r1); + if (!ok1) { + cbm_mcp_server_free(srv); + return IDX773_FIRST_FAILED; + } + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_b); + char *r2 = cbm_mcp_handle_tool(srv, "index_repository", args); /* SIGABRT here (RED) */ + bool ok2 = r2 && strstr(r2, "indexed") != NULL; + if (!ok2) { + fprintf(stderr, " second in-process index response: %.*s\n", CBM_SZ_4K, + r2 ? r2 : "(null)"); + } + free(r2); cbm_mcp_server_free(srv); - PASS(); + return ok2 ? IDX773_OK : IDX773_SECOND_FAILED; } +#endif /* !_WIN32 */ -/* ══════════════════════════════════════════════════════════════════ - * URI HELPERS - * ══════════════════════════════════════════════════════════════════ */ +/* #898: the SEQUENTIAL pipeline emitted malformed JSON for brokered + * ASYNC_CALLS edges ("broker":"bullmq} — missing closing quote) and stored + * the RAW broker/method string as the synthesized Route node's properties + * (literally `bullmq` instead of {"broker":"bullmq"}). json_extract over + * those rows errors, generated-column indexes fail, and PRAGMA quick_check + * aborts with "malformed JSON" — which since the artifact deep-integrity + * check also means such caches are refused at import. The parallel path + * was correct; both pipelines must emit identical, valid JSON. */ +TEST(sequential_service_edge_props_are_valid_json_issue898) { + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_seq898_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("mkdtemp failed"); + } + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm_seq898_cache_XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp); + FAIL("cache mkdtemp failed"); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); -TEST(parse_file_uri_unix) { - char path[256]; - ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/project", path, sizeof(path))); - ASSERT_STR_EQ(path, "/home/user/project"); + char src_path[CBM_SZ_512]; + snprintf(src_path, sizeof(src_path), "%s/queue.py", tmp); + FILE *f = fopen(src_path, "w"); + ASSERT_NOT_NULL(f); + /* celery.Celery("tasks") resolves through the import map to a QN the + * service-pattern table classifies as ASYNC with broker "celery". */ + fputs("import celery\n" + "\n" + "def enqueue():\n" + " celery.Celery(\"tasks\")\n", + f); + fclose(f); - ASSERT_TRUE(cbm_parse_file_uri("file:///tmp/test", path, sizeof(path))); - ASSERT_STR_EQ(path, "/tmp/test"); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + free(resp); - ASSERT_TRUE(cbm_parse_file_uri("file:///", path, sizeof(path))); - ASSERT_STR_EQ(path, "/"); - PASS(); -} + /* File-backed MCP stores are deliberately request-scoped (release_request_store, + * src/mcp/mcp.c) so a sibling process can atomically replace the DB generation and + * so Windows retains no replacement-blocking handle. index_repository resolves a + * file-backed store, so this server holds no cached handle once the call returns; + * inspect the published DB through an independent query handle. The capability that + * makes this necessary is pinned by + * file_backed_store_is_released_at_request_end_not_pinned. */ + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + char db_path[CBM_SZ_512]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(store); + struct sqlite3 *db = cbm_store_get_db(store); + ASSERT_NOT_NULL(db); -TEST(parse_file_uri_windows) { - char path[256]; - /* Windows drive letter — leading / stripped */ - ASSERT_TRUE(cbm_parse_file_uri("file:///C:/Users/project", path, sizeof(path))); - ASSERT_STR_EQ(path, "C:/Users/project"); + /* Non-vacuous: the fixture must actually produce a brokered edge. */ + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(db, "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS';", -1, + &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int async_edges = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_TRUE(async_edges >= 1); - ASSERT_TRUE(cbm_parse_file_uri("file:///D:/Projects/myapp", path, sizeof(path))); - ASSERT_STR_EQ(path, "D:/Projects/myapp"); - PASS(); -} + /* THE BUG: malformed properties on edges (broker quote) and Route nodes + * (raw string). Every properties blob must be valid JSON. */ + ASSERT_EQ(sqlite3_prepare_v2(db, + "SELECT count(*) FROM edges WHERE properties IS NOT NULL " + "AND properties != '' AND json_valid(properties)=0;", + -1, &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int bad_edges = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_EQ(bad_edges, 0); -TEST(parse_file_uri_invalid) { - char path[256]; - /* Non-file URI */ - ASSERT_FALSE(cbm_parse_file_uri("https://example.com", path, sizeof(path))); - ASSERT_STR_EQ(path, ""); + ASSERT_EQ(sqlite3_prepare_v2(db, + "SELECT count(*) FROM nodes WHERE properties IS NOT NULL " + "AND properties != '' AND json_valid(properties)=0;", + -1, &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int bad_nodes = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_EQ(bad_nodes, 0); - /* Empty string */ - ASSERT_FALSE(cbm_parse_file_uri("", path, sizeof(path))); - ASSERT_STR_EQ(path, ""); + /* Pipeline parity: the broker must be extractable exactly like the + * parallel path emits it. */ + ASSERT_EQ(sqlite3_prepare_v2(db, + "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS' AND " + "json_extract(properties,'$.broker')='celery';", + -1, &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int brokered = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_TRUE(brokered >= 1); - /* NULL */ - ASSERT_FALSE(cbm_parse_file_uri(NULL, path, sizeof(path))); + cbm_store_close(store); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + th_rmtree(cache); + unlink(src_path); + cbm_rmdir(tmp); PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * SNIPPET TESTS — Port of internal/tools/snippet_test.go - * ══════════════════════════════════════════════════════════════════ */ - -#include -#include -#include - -/* Create an MCP server pre-populated with nodes/edges matching Go testSnippetServer. - * Writes a source file to tmp_dir/project/main.go. - * Caller must free the server with cbm_mcp_server_free and - * unlink the source file + rmdir manually. */ -static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz) { - /* Create temp dir */ - snprintf(tmp_dir, tmp_sz, "/tmp/cbm_snippet_test_XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) - return NULL; - - char proj_dir[512]; - snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp_dir); - cbm_mkdir(proj_dir); - - /* Write sample source file */ - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir); - FILE *fp = fopen(src_path, "w"); - if (!fp) - return NULL; - fprintf(fp, "package main\n" - "\n" - "func HandleRequest() error {\n" - "\treturn nil\n" - "}\n" - "\n" - "func ProcessOrder(id int) {\n" - "\t// process\n" - "}\n" - "\n" - "func Run() {\n" - "\t// server\n" - "}\n"); - fclose(fp); - - /* Create server with in-memory store */ - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) - return NULL; +/* index_repository used to accept any mode spelling and silently fall back to + * CBM_MODE_FULL, so a typo, or the internal-only "dep", produced a different index + * than the caller asked for and still reported success. Assert the rejection names + * the offending value and the accepted set, and that the accepted set is derived + * from CBM_INDEX_MODE_TABLE rather than restated in the message. */ +TEST(index_repository_rejects_unknown_mode_instead_of_silent_full) { + char accepted[CBM_SZ_64]; + int accepted_len = cbm_index_mode_accepted(accepted, (int)sizeof(accepted)); + ASSERT_TRUE(accepted_len > 0); + /* Every caller-selectable spelling appears; the internal-only one does not. */ + ASSERT_NOT_NULL(strstr(accepted, "full")); + ASSERT_NOT_NULL(strstr(accepted, "moderate")); + ASSERT_NOT_NULL(strstr(accepted, "fast")); + ASSERT_NULL(strstr(accepted, "dep")); + + /* The parser and the emitter agree in both directions for every spelling. */ + cbm_index_mode_t parsed = CBM_MODE_FULL; + bool selectable = false; + ASSERT_TRUE(cbm_index_mode_from_name("fast", &parsed, &selectable)); + ASSERT_EQ(parsed, CBM_MODE_FAST); + ASSERT_TRUE(selectable); + ASSERT_STR_EQ(cbm_index_mode_name(CBM_MODE_FAST), "fast"); + ASSERT_TRUE(cbm_index_mode_from_name("dep", &parsed, &selectable)); + ASSERT_EQ(parsed, CBM_MODE_DEP); + ASSERT_FALSE(selectable); + ASSERT_FALSE(cbm_index_mode_from_name("fsat", &parsed, &selectable)); - cbm_store_t *st = cbm_mcp_server_store(srv); - if (!st) { - cbm_mcp_server_free(srv); - return NULL; + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_modevocab_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("mkdtemp failed"); } + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm_modevocab_cache_XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp); + FAIL("cache mkdtemp failed"); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); - const char *proj_name = "test-project"; - cbm_mcp_server_set_project(srv, proj_name); - cbm_store_upsert_project(st, proj_name, proj_dir); - - /* Create nodes */ - cbm_node_t n_hr = {0}; - n_hr.project = proj_name; - n_hr.label = "Function"; - n_hr.name = "HandleRequest"; - n_hr.qualified_name = "test-project.cmd.server.main.HandleRequest"; - n_hr.file_path = "main.go"; - n_hr.start_line = 3; - n_hr.end_line = 5; - n_hr.properties_json = "{\"signature\":\"func HandleRequest() error\"," - "\"return_type\":\"error\"," - "\"is_exported\":true}"; - int64_t id_hr = cbm_store_upsert_node(st, &n_hr); - - cbm_node_t n_po = {0}; - n_po.project = proj_name; - n_po.label = "Function"; - n_po.name = "ProcessOrder"; - n_po.qualified_name = "test-project.cmd.server.main.ProcessOrder"; - n_po.file_path = "main.go"; - n_po.start_line = 7; - n_po.end_line = 9; - n_po.properties_json = "{\"signature\":\"func ProcessOrder(id int)\"}"; - int64_t id_po = cbm_store_upsert_node(st, &n_po); - - cbm_node_t n_run1 = {0}; - n_run1.project = proj_name; - n_run1.label = "Function"; - n_run1.name = "Run"; - n_run1.qualified_name = "test-project.cmd.server.Run"; - n_run1.file_path = "main.go"; - n_run1.start_line = 11; - n_run1.end_line = 13; - int64_t id_run1 = cbm_store_upsert_node(st, &n_run1); - - cbm_node_t n_run2 = {0}; - n_run2.project = proj_name; - n_run2.label = "Function"; - n_run2.name = "Run"; - n_run2.qualified_name = "test-project.cmd.worker.Run"; - n_run2.file_path = "main.go"; - n_run2.start_line = 11; - n_run2.end_line = 13; - cbm_store_upsert_node(st, &n_run2); - - /* Create edges: HandleRequest -> ProcessOrder, HandleRequest -> Run1 */ - cbm_edge_t e1 = {.project = proj_name, .source_id = id_hr, .target_id = id_po, .type = "CALLS"}; - cbm_store_insert_edge(st, &e1); + char src_path[CBM_SZ_512]; + snprintf(src_path, sizeof(src_path), "%s/mod.py", tmp); + FILE *f = fopen(src_path, "w"); + ASSERT_NOT_NULL(f); + fputs("def handler():\n return 1\n", f); + fclose(f); - cbm_edge_t e2 = { - .project = proj_name, .source_id = id_hr, .target_id = id_run1, .type = "CALLS"}; - cbm_store_insert_edge(st, &e2); - (void)id_run1; /* run1 used for edge above */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); - return srv; -} + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fsat\"}", tmp); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + /* Loud: names the bad value and the accepted set, and did NOT index. */ + ASSERT_NOT_NULL(strstr(resp, "fsat")); + ASSERT_NOT_NULL(strstr(resp, accepted)); + ASSERT_NULL(strstr(resp, "\"status\":\"indexed\"")); + free(resp); -/* Cleanup temp files created by setup_snippet_server */ -static void cleanup_snippet_dir(const char *tmp_dir) { - char path[512]; - snprintf(path, sizeof(path), "%s/project/main.go", tmp_dir); - unlink(path); - snprintf(path, sizeof(path), "%s/project", tmp_dir); - rmdir(path); - rmdir(tmp_dir); -} + /* The internal-only spelling is rejected the same way, not treated as full. */ + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"dep\"}", tmp); + resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"status\":\"indexed\"")); + free(resp); -/* Extract the inner "text" value from an MCP tool result JSON. - * The MCP envelope is: {"content":[{"type":"text","text":""}]} - * This returns the unescaped inner JSON. Caller must free. */ -static char *extract_text_content(const char *mcp_result) { - if (!mcp_result) - return NULL; - yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0); - if (!doc) - return strdup(mcp_result); /* fallback */ - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_val *content = yyjson_obj_get(root, "content"); - if (!content) { - /* Handle JSON-RPC wrapper: {"jsonrpc":...,"result":{"content":[...]}} */ - yyjson_val *rpc_result = yyjson_obj_get(root, "result"); - if (rpc_result) { - content = yyjson_obj_get(rpc_result, "content"); - } - } - if (!content || !yyjson_is_arr(content)) { - yyjson_doc_free(doc); - return strdup(mcp_result); - } - yyjson_val *item = yyjson_arr_get(content, 0); - if (!item) { - yyjson_doc_free(doc); - return strdup(mcp_result); - } - yyjson_val *text = yyjson_obj_get(item, "text"); - const char *str = yyjson_get_str(text); - char *result = str ? strdup(str) : strdup(mcp_result); - yyjson_doc_free(doc); - return result; -} + /* A rejected mode must not leave the project mutation held: a valid run still + * succeeds afterwards on the same server. */ + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp); + resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + free(resp); -/* Call get_code_snippet and extract inner text content. - * Caller must free returned string. */ -static char *call_snippet(cbm_mcp_server_t *srv, const char *args_json) { - char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", args_json); - char *text = extract_text_content(raw); - free(raw); - return text; + cbm_mcp_server_free(srv); + char *project = cbm_project_name_from_path(tmp); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + th_rmtree(cache); + unlink(src_path); + cbm_rmdir(tmp); + PASS(); } -static bool is_valid_json_response(const char *json) { - if (!json) { - return false; - } - yyjson_doc *doc = yyjson_read(json, strlen(json), 0); - if (!doc) { - return false; +/* Companion pinning the capability the test above accommodates. The branch parent + * had no request-scoped release, so its copy of that test read the session handle + * directly; upstream added release_request_store (src/mcp/mcp.c) and rewrote its + * copy to open the DB by path. Merging both left the branch's body running against + * upstream's release, which nulled the handle and made a successful index look like + * a session holding no store. Assert the release itself: without it a caller pins a + * superseded DB generation, and on Windows that retained handle blocks the atomic + * replacement that publishes the next index. The in-memory exemption is asserted in + * the same test so neither half can regress silently. */ +TEST(file_backed_store_is_released_at_request_end_not_pinned) { + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_reqscope_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("mkdtemp failed"); } - yyjson_doc_free(doc); - return true; -} - -static bool snippet_source_has_replacement(const char *json) { - yyjson_doc *doc = yyjson_read(json, strlen(json), 0); - if (!doc) { - return false; + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm_reqscope_cache_XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp); + FAIL("cache mkdtemp failed"); } - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_val *source = yyjson_obj_get(root, "source"); - const char *source_str = yyjson_get_str(source); - bool found = source_str && strstr(source_str, "\xEF\xBF\xBD"); - yyjson_doc_free(doc); - return found; -} + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); -/* ── TestSnippet_ExactQN ──────────────────────────────────────── */ + char src_path[CBM_SZ_512]; + snprintf(src_path, sizeof(src_path), "%s/mod.py", tmp); + FILE *f = fopen(src_path, "w"); + ASSERT_NOT_NULL(f); + fputs("def handler():\n return 1\n", f); + fclose(f); -TEST(snippet_exact_qn) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char *resp = - call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," - "\"project\":\"test-project\"}"); + /* A pristine in-memory store has no path, so the request-scoped release must + * leave it alone: embedded and test callers keep it for the process lifetime. */ + cbm_store_t *pristine = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(pristine); + ASSERT_NULL(cbm_store_db_path(pristine)); + + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); - ASSERT_NOT_NULL(strstr(resp, "\"source\"")); - /* Exact match should NOT have match_method */ - ASSERT_NULL(strstr(resp, "\"match_method\"")); - /* No property-blob spill: the source IS the payload (signature and - * docstring are literally in it); metrics live behind search_graph - * fields=[...]. */ - ASSERT_NULL(strstr(resp, "\"signature\"")); - ASSERT_NULL(strstr(resp, "\"return_type\"")); - /* Caller/callee counts: 0 callers, 2 callees */ - ASSERT_NOT_NULL(strstr(resp, "\"callers\":0")); - ASSERT_NOT_NULL(strstr(resp, "\"callees\":2")); + ASSERT_NOT_NULL(strstr(resp, "indexed")); free(resp); + /* The request resolved a file-backed store; none may be retained past the call. */ + ASSERT_NULL(cbm_mcp_server_store(srv)); + + /* Non-vacuous: the published DB exists and holds the indexed graph, so the NULL + * above is a released handle rather than an index that never happened. */ + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + char db_path[CBM_SZ_512]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *published = cbm_store_open_path_query(db_path); + ASSERT_NOT_NULL(published); + struct sqlite3 *db = cbm_store_get_db(published); + ASSERT_NOT_NULL(db); + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(db, "SELECT count(*) FROM nodes;", -1, &stmt, NULL), SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int nodes = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_TRUE(nodes >= 1); + cbm_store_close(published); + cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + th_rmtree(cache); + unlink(src_path); + cbm_rmdir(tmp); PASS(); } -/* ── TestSnippet_QNSuffix ─────────────────────────────────────── */ +TEST(resolve_store_validates_and_serves_with_one_query_open) { + const char *cache = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache); + const char *project = "single-open-project"; + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(db_path, sizeof(db_path), cache, project), CBM_STORE_OK); + ASSERT_TRUE(mcp_create_generation_db(db_path, project, "Function", "OneOpen")); -TEST(snippet_qn_suffix) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + char *response = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"single-open-project\",\"name_pattern\":\"OneOpen\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "OneOpen")); + free(response); - char *resp = call_snippet(srv, "{\"qualified_name\":\"main.HandleRequest\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); - ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); - ASSERT_NOT_NULL(strstr(resp, "\"source\"")); - free(resp); + ASSERT_EQ(cbm_mcp_server_query_store_open_count_for_testing(srv), 1); + cbm_mcp_server_free(srv); + mcp_unlink_db_sidecars(db_path); + PASS(); +} + +/* Benchmark-only ablation seam: the product still has to close the file-backed + * SQLite handle at request end, but a TEST_SEAMS build can suppress the + * allocator-wide mi_collect(true) independently. This separates close/reopen + * correctness and cost from allocator collection without adding a production + * mode or retaining a publication-blocking handle. */ +TEST(request_store_release_collection_can_be_isolated_for_measurement) { + const char *cache = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache); + const char *project = "request-collect-ablation-project"; + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(db_path, sizeof(db_path), cache, project), CBM_STORE_OK); + ASSERT_TRUE(mcp_create_generation_db(db_path, project, "Function", "CollectAblation")); + + const char *saved = getenv("CBM_TEST_SKIP_REQUEST_MEM_COLLECT"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_TEST_SKIP_REQUEST_MEM_COLLECT", "1", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + bool server_created = srv != NULL; + char *response = + srv ? cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"request-collect-ablation-project\"," + "\"name_pattern\":\"CollectAblation\",\"format\":\"json\"}") + : NULL; + bool returned_result = response && strstr(response, "CollectAblation") != NULL; + bool released_store = srv && cbm_mcp_server_store(srv) == NULL; + uint64_t collection_count = + srv ? cbm_mcp_server_request_mem_collect_count_for_testing(srv) : UINT64_MAX; + free(response); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + if (saved_copy) { + cbm_setenv("CBM_TEST_SKIP_REQUEST_MEM_COLLECT", saved_copy, 1); + } else { + cbm_unsetenv("CBM_TEST_SKIP_REQUEST_MEM_COLLECT"); + } + free(saved_copy); + mcp_unlink_db_sidecars(db_path); + + ASSERT_TRUE(server_created); + ASSERT_TRUE(returned_result); + ASSERT_TRUE(released_store); + ASSERT_EQ(collection_count, 0); + PASS(); +} + +/* Benchmark-only ablation seam: retaining a file-backed query store is unsafe + * as a portable product default until POSIX generation detection and Windows + * publication behavior are proven separately. A TEST_SEAMS build may retain + * it to measure the complete open/validate/integrity/close lifecycle without + * conflating that cost with query execution. Two same-project requests should + * then remain one O(schema + integrity) open followed by an O(1) cached lookup, + * with one live SQLite page cache owned by the server until teardown. */ +TEST(request_store_retention_can_be_isolated_for_measurement) { + const char *cache = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache); + const char *project = "request-store-retention-ablation-project"; + char db_path[CBM_PATH_MAX]; + ASSERT_EQ(mcp_project_db_path(db_path, sizeof(db_path), cache, project), CBM_STORE_OK); + ASSERT_TRUE(mcp_create_generation_db(db_path, project, "Function", "RetainedStore")); + + const char *saved = getenv("CBM_TEST_RETAIN_REQUEST_STORE"); + char *saved_copy = saved ? cbm_strdup(saved) : NULL; + cbm_setenv("CBM_TEST_RETAIN_REQUEST_STORE", "1", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *first = + srv ? cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"request-store-retention-ablation-project\"," + "\"name_pattern\":\"RetainedStore\",\"format\":\"json\"}") + : NULL; + char *second = + srv ? cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"request-store-retention-ablation-project\"," + "\"name_pattern\":\"RetainedStore\",\"format\":\"json\"}") + : NULL; + bool returned_both = first && second && strstr(first, "RetainedStore") && + strstr(second, "RetainedStore"); + bool retained_store = srv && cbm_mcp_server_store(srv) != NULL; + uint64_t open_count = + srv ? cbm_mcp_server_query_store_open_count_for_testing(srv) : UINT64_MAX; + + free(first); + free(second); + cbm_mcp_server_free(srv); + if (saved_copy) { + cbm_setenv("CBM_TEST_RETAIN_REQUEST_STORE", saved_copy, 1); + } else { + cbm_unsetenv("CBM_TEST_RETAIN_REQUEST_STORE"); + } + free(saved_copy); + mcp_unlink_db_sidecars(db_path); + + ASSERT_TRUE(returned_both); + ASSERT_TRUE(retained_store); + ASSERT_EQ(open_count, 1); + PASS(); +} + +TEST(index_second_inprocess_run_survives_issue773) { +#ifdef _WIN32 + SKIP_PLATFORM("fork-isolated crash guard (POSIX-only)"); +#else + char dir_a[CBM_SZ_256]; + char dir_b[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(dir_a, sizeof(dir_a), "/tmp/cbm-idx773a-XXXXXX"); + snprintf(dir_b, sizeof(dir_b), "/tmp/cbm-idx773b-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-idx773c-XXXXXX"); + if (!cbm_mkdtemp(dir_a) || !cbm_mkdtemp(dir_b) || !cbm_mkdtemp(cache)) { + FAIL("mkdtemp failed"); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + /* Trigger shape: run 1 small enough for the SEQUENTIAL path (parses on + * the calling thread, mimalloc epoch), run 2 large enough for the + * PARALLEL path (switches the global ts allocator to the slab). */ + idx773_write_py_repo(dir_a, 5, 0); + idx773_write_py_repo(dir_b, 60, 1); + + int code = -1; + bool signalled = false; + int sig = 0; + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(180); /* generous: two full parallel indexes */ + _exit(idx773_double_index_check(dir_a, dir_b)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } + + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + + if (signalled) { + printf(" child killed by signal %d (SIGABRT = the #773 invalid free)\n", sig); + } else if (code != IDX773_OK) { + printf(" child exit code %d (71=first index failed, 72=second failed)\n", code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDX773_OK); PASS(); +#endif } -/* ── TestSnippet_UniqueShortName ──────────────────────────────── */ +TEST(index_recovery_parallel_quarantines_crasher) { +#ifdef _WIN32 + SKIP_PLATFORM("parallel-recovery guard needs fork isolation (POSIX-only)"); +#else + char tmp_dir[CBM_SZ_256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idxpar-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + FAIL("mkdtemp failed"); + } + char cache[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-idxpar-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + FAIL("mkdtemp cache failed"); + } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); -TEST(snippet_unique_short_name) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); + char p1[CBM_SZ_512]; + char p2[CBM_SZ_512]; + char pc[CBM_SZ_512]; + snprintf(p1, sizeof(p1), "%s/idxpar_good_a.py", tmp_dir); + snprintf(p2, sizeof(p2), "%s/idxpar_good_b.py", tmp_dir); + snprintf(pc, sizeof(pc), "%s/idxpar_crasher.py", tmp_dir); + FILE *f = fopen(p1, "w"); + ASSERT_NOT_NULL(f); + fputs("def idxpar_good_fn():\n return 'ok'\n", f); + fclose(f); + f = fopen(p2, "w"); + ASSERT_NOT_NULL(f); + fputs("def idxpar_good_fn_b():\n return 'ok'\n", f); + fclose(f); + f = fopen(pc, "w"); + ASSERT_NOT_NULL(f); + fputs("def idxpar_crash_fn():\n return 'boom'\n", f); + fclose(f); - /* "ProcessOrder" is unique — suffix tier matches (QN ends with .ProcessOrder) */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"ProcessOrder\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"ProcessOrder\"")); - ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); - ASSERT_NOT_NULL(strstr(resp, "\"source\"")); - free(resp); + int code = -1; + bool signalled = false; + int sig = 0; + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(120); /* generous: three supervised rounds + clean run */ + _exit(idxpar_recovery_check(tmp_dir)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + char *project = cbm_project_name_from_path(tmp_dir); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + remove(p1); + remove(p2); + remove(pc); + th_rmtree(cache); + cbm_rmdir(tmp_dir); + + if (signalled) { + printf(" child killed by signal %d (alarm => recovery loop hang)\n", sig); + } else if (code != IDXPAR_OK) { + printf(" child exit code %d (61=ST spawn/RED, 62=null resp, 63=not indexed, " + "64=no quarantine, 65=innocent hit, 66=good missing)\n", + code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDXPAR_OK); PASS(); +#endif } -/* ── TestSnippet_NameTier ─────────────────────────────────────── */ +/* ══════════════════════════════════════════════════════════════════ + * AUTO_WATCH GATE (distilled from PR #625) + * + * Background watcher registration on session connect is gated by the + * `auto_watch` config key (default TRUE = existing behavior). + * ══════════════════════════════════════════════════════════════════ */ -TEST(snippet_name_tier) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); +/* Drive the already-indexed connect path (initialize → maybe_auto_index → + * watcher registration) and return the resulting watch count. + * auto_watch_value: NULL leaves the key unset (exercises the default), + * otherwise the key is set to that value before initialize. + * Returns a negative code on fixture setup failure. */ +static int auto_watch_connect_watch_count(const char *auto_watch_value) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-autowatch-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + return -1; + } - /* "HandleRequest" — suffix tier finds it (QN ends with .HandleRequest) */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"HandleRequest\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); - ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); - free(resp); + char repodir[512]; + snprintf(repodir, sizeof(repodir), "%s/repo", cache); + if (th_mkdir_p(repodir) != 0) { + th_rmtree(cache); + return -2; + } - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); -} + /* Same derivation detect_session uses on the cwd — realpath-based, so + * the name matches even where /tmp is a symlink (macOS). */ + char *project = cbm_project_name_from_path(repodir); + if (!project) { + th_rmtree(cache); + return -3; + } -/* ── TestSnippet_AmbiguousShortName ───────────────────────────── */ + /* Pre-create a valid indexed project so maybe_auto_index takes the + * "already indexed" branch — the watcher-registration site under test. */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *indexed_store = cbm_store_open_path(db_path); + if (!indexed_store || + cbm_store_upsert_project(indexed_store, project, repodir) != CBM_STORE_OK) { + cbm_store_close(indexed_store); + free(project); + th_rmtree(cache); + return -4; + } + cbm_node_t indexed_node = {.project = project, + .label = "Project", + .name = project, + .qualified_name = project, + .file_path = ""}; + if (cbm_store_upsert_node(indexed_store, &indexed_node) <= 0) { + cbm_store_close(indexed_store); + free(project); + th_rmtree(cache); + return -4; + } + cbm_store_close(indexed_store); + free(project); -TEST(snippet_ambiguous_short_name) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); - /* "Run" matches 2 nodes — should return suggestions */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\"")); - ASSERT_NOT_NULL(strstr(resp, "\"message\"")); - ASSERT_NOT_NULL(strstr(resp, "\"suggestions\"")); - /* Must NOT have "error" key */ - ASSERT_NULL(strstr(resp, "\"error\"")); - /* Must NOT have "source" */ - ASSERT_NULL(strstr(resp, "\"source\"")); - /* Should have at least 2 suggestions with qualified_name */ - ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.server.Run")); - ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.worker.Run")); - free(resp); + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repodir) != 0) { + restore_cache_dir(saved_copy); + free(saved_copy); + th_rmtree(cache); + return -5; + } - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); -} + int count = -6; + cbm_config_t *cfg = cbm_config_open(cache); + cbm_store_t *wstore = cbm_store_open_memory(); + cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL; + if (cfg && watcher) { + if (auto_watch_value) { + cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, auto_watch_value); + } -/* ── TestSnippet_NotFound ─────────────────────────────────────── */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_watcher(srv, watcher); + cbm_mcp_server_set_config(srv, cfg); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); + free(resp); + count = cbm_watcher_watch_count(watcher); + cbm_mcp_server_free(srv); + } + } -TEST(snippet_not_found) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); + if (watcher) { + cbm_watcher_free(watcher); + } + if (wstore) { + cbm_store_close(wstore); + } + if (cfg) { + cbm_config_close(cfg); + } - char *resp = call_snippet(srv, "{\"qualified_name\":\"CompletelyNonexistentFunctionXYZ123\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - /* Should return error or suggestions */ - ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "suggestions")); - free(resp); + (void)cbm_chdir(old_cwd); + restore_cache_dir(saved_copy); + free(saved_copy); + th_rmtree(cache); + return count; +} - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); +/* Default (key unset) → watcher registered on connect. Guards the + * no-behavior-change promise of the auto_watch gate: existing users keep + * background auto-sync without touching config. */ +TEST(mcp_auto_watch_default_registers_watcher_on_connect) { + int count = auto_watch_connect_watch_count(NULL); + if (count < 0) { + PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */ + } + ASSERT_EQ(count, 1); PASS(); } -/* ── TestSnippet_FuzzySuggestions ─────────────────────────────── */ - -TEST(snippet_fuzzy_suggestions) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); - - /* "Handle" is not an exact QN or suffix — should get not-found guidance */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"Handle\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - /* Should guide user to search_graph */ - ASSERT_NOT_NULL(strstr(resp, "search_graph")); - free(resp); - - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); +/* auto_watch=false → NO watcher registered on connect. RED on pre-gate code + * (registration was unconditional and the key did not exist). */ +TEST(mcp_auto_watch_false_skips_watcher_on_connect) { + int count = auto_watch_connect_watch_count("false"); + if (count < 0) { + PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */ + } + ASSERT_EQ(count, 0); PASS(); } -/* ── TestSnippet_EnrichedProperties ───────────────────────────── */ +/* ══════════════════════════════════════════════════════════════════ + * #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index + * watcher registration (keystone × #849 merge interaction) + * ══════════════════════════════════════════════════════════════════ */ -TEST(snippet_enriched_properties) { - /* GUARD (inverted since the compact-output change): the snippet response - * carries the verbatim source plus location/degree/coverage metadata and - * NOTHING from the node's property blob — no signature/return_type/ - * is_exported duplication, and never the fp/sp/bt similarity internals - * (41% of the legacy response). */ - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); +/* #849 routed ALL watcher registration through register_watcher_if_enabled() + * (auto_watch gate). The #832 keystone then added a SECOND registration site in + * autoindex_thread's supervised-success branch, but wired it as a DIRECT + * cbm_watcher_watch() guarded only by `if (srv->watcher)` — srv->watcher is set + * unconditionally, so that guard does NOT honour `config set auto_watch false`. + * The above tests only cover the already-indexed on-connect path + * (register_watcher_if_enabled); this guard covers the fresh-index SUPERVISED + * autoindex_thread branch that #832 introduced. + * + * Drive the real public entry initialize → maybe_auto_index → autoindex_thread on + * a supervisor-marked host (kill switch off) with a FRESH project (no prior .db) + * and auto_watch=false. cbm_mcp_server_free() joins the autoindex thread, so the + * (buggy or gated) registration decision has run before we read the watch count. + * + * RED on the unfixed ungated block: the supervised success branch calls + * cbm_watcher_watch() unconditionally → watch_count == 1 → IDX853_WATCHER_REGISTERED. + * GREEN once it calls register_watcher_if_enabled() → auto_watch_off skip → 0. + * spawn_count is asserted to have advanced so the assertion cannot pass vacuously + * (i.e. green only because the supervised branch was never entered). */ +enum { + IDX853_OK = 0, /* watch_count==0, supervised branch ran → GREEN */ + IDX853_WATCHER_REGISTERED = 61, /* watch_count==1 → RED: ungated cbm_watcher_watch */ + IDX853_NO_SPAWN = 62, /* spawn_count unchanged → supervised path not exercised */ + IDX853_SETUP_FAIL = 63, /* config/watcher/server/cwd setup failed */ + IDX853_BAD_COUNT = 64, /* unexpected watch_count (<0 or >1) */ +}; - char *resp = - call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"source\"")); - ASSERT_NULL(strstr(resp, "\"signature\"")); - ASSERT_NULL(strstr(resp, "\"return_type\"")); - ASSERT_NULL(strstr(resp, "\"is_exported\"")); - ASSERT_NULL(strstr(resp, "\"fp\"")); - ASSERT_NULL(strstr(resp, "\"bt\"")); - free(resp); +#ifndef _WIN32 /* helper used only by the POSIX fork harness below */ +static int idx853_supervised_autowatch_check(const char *repo_dir, const char *cache_dir) { + /* Become a supervisor host with the kill switch OFF — the real prod MCP + * server's state. Done in the FORKED CHILD only (see harness) so the parent + * test-runner's process-wide host mark stays clear (#845 invariant). Bound the + * worker so a stuck spawn cannot run long under the fork+alarm net. */ + cbm_index_supervisor_mark_host(); + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); + cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); -} + cbm_config_t *cfg = cbm_config_open(cache_dir); + cbm_store_t *wstore = cbm_store_open_memory(); + cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL; + if (!cfg || !watcher) { + if (watcher) { + cbm_watcher_free(watcher); + } + if (wstore) { + cbm_store_close(wstore); + } + if (cfg) { + cbm_config_close(cfg); + } + return IDX853_SETUP_FAIL; + } + /* auto_index=true → maybe_auto_index launches autoindex_thread for the fresh + * project; auto_watch=false → the gate this guard exercises. */ + cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); + cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "false"); -/* ── TestSnippet_FuzzyLastSegment ─────────────────────────────── */ + /* detect_session derives session_root/session_project from the cwd. */ + char old_cwd[1024]; + if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repo_dir) != 0) { + cbm_watcher_free(watcher); + cbm_store_close(wstore); + cbm_config_close(cfg); + return IDX853_SETUP_FAIL; + } -TEST(snippet_fuzzy_last_segment) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); + int spawns_before = cbm_index_supervisor_spawn_count(); + int code = IDX853_SETUP_FAIL; - /* "auth.handlers.HandleRequest" — suffix match should find HandleRequest */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"auth.handlers.HandleRequest\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - /* Should either find it via suffix or guide to search_graph */ - ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL || strstr(resp, "search_graph") != NULL); - free(resp); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (srv) { + cbm_mcp_server_set_watcher(srv, watcher); + cbm_mcp_server_set_config(srv, cfg); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); + free(resp); + /* Wait for the supervised worker to finish so the registration decision + * (buggy or gated) has executed; free() is now a cancellation boundary. */ + (void)cbm_mcp_server_join_autoindex(srv); + cbm_mcp_server_free(srv); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); - PASS(); + int spawns_after = cbm_index_supervisor_spawn_count(); + int watch_count = cbm_watcher_watch_count(watcher); + + if (spawns_after == spawns_before) { + code = IDX853_NO_SPAWN; /* supervised branch never ran — not a valid probe */ + } else if (watch_count == 1) { + code = IDX853_WATCHER_REGISTERED; /* the discriminating RED assertion */ + } else if (watch_count == 0) { + code = IDX853_OK; + } else { + code = IDX853_BAD_COUNT; + } + } + + (void)cbm_chdir(old_cwd); + cbm_watcher_free(watcher); + cbm_store_close(wstore); + cbm_config_close(cfg); + return code; } +#endif /* !_WIN32 */ -/* ── TestSnippet_AutoResolve_Default ──────────────────────────── */ +TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853) { +#ifdef _WIN32 + /* Marks the process as a supervisor host (irreversible); POSIX isolates that + * in a forked child. The gate logic is platform-independent and covered on + * POSIX CI. */ + SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)"); +#else + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx853-repo-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-idx853-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } -TEST(snippet_auto_resolve_default) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */ - /* "Run" is ambiguous (2 candidates). Without auto_resolve → suggestions */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\"")); - ASSERT_NULL(strstr(resp, "\"source\"")); - free(resp); + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def idx853_fn():\n return 'ok'\n", fp); + fclose(fp); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + int code = -1; + bool signalled = false; + int sig = 0; + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(60); /* a stuck worker dies here instead of hanging the runner */ + _exit(idx853_supervised_autowatch_check(tmp_dir, cache)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } + + char *project = cbm_project_name_from_path(tmp_dir); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + + if (signalled) { + printf(" child killed by signal %d (alarm => worker hang)\n", sig); + } else if (code != IDX853_OK) { + printf(" child exit code %d (61=watcher registered under auto_watch=false=RED, " + "62=no spawn, 63=setup fail, 64=bad count)\n", + code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDX853_OK); PASS(); +#endif } -/* ── TestSnippet_AutoResolve_Enabled ──────────────────────────── */ - -TEST(snippet_auto_resolve_enabled) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); +/* The containment guard both MCP file-read sinks route through + * (resolve_snippet_source for get_code_snippet, attach_result_source for + * search_code). A result path that resolves outside the indexed project root + * — via a `..` segment or a followed symlink/junction — must be rejected so + * its contents never reach a tool response. */ +extern bool cbm_path_within_root(const char *root_path, const char *abs_path); - /* "Run" — suffix match should find candidates or guide to search */ - char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\"," - "\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - /* "Run" matches multiple nodes via suffix → should get suggestions or source */ - ASSERT_TRUE(strstr(resp, "Run") != NULL); - free(resp); +TEST(mcp_path_within_root_rejects_escape) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX realpath repro; the Windows _fullpath branch is the same guard"); +#else + char root[512]; + snprintf(root, sizeof(root), "%s/cbm_pwr_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(root)) { + FAIL("cbm_mkdtemp failed"); + } + char inside[700]; + snprintf(inside, sizeof(inside), "%s/inside.c", root); + FILE *fp = fopen(inside, "w"); + ASSERT_NOT_NULL(fp); + fputs("int x;\n", fp); + fclose(fp); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + /* The abs_path a sink builds for an in-root result stays contained; a `..` + * escape to an existing outside file (/etc/hosts) resolves out and must be + * rejected. */ + char escape[900]; + snprintf(escape, sizeof(escape), "%s/../../../../etc/hosts", root); + ASSERT_TRUE(cbm_path_within_root(root, inside)); + ASSERT_FALSE(cbm_path_within_root(root, escape)); + ASSERT_FALSE(cbm_path_within_root(root, "/etc/hosts")); + + remove(inside); + cbm_rmdir(root); PASS(); +#endif } -/* ── TestSnippet_IncludeNeighbors_Default ─────────────────────── */ - -TEST(snippet_include_neighbors_default) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); +/* A leading '-' is not a valid branch spelling. Reject it before spawning Git + * instead of depending on command-specific --end-of-options support. */ +TEST(detect_changes_rejects_option_like_base_branch_before_git) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - - char *resp = - call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," - "\"project\":\"test-project\"}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"detect_changes\"," + "\"arguments\":{\"project\":\"option-argv-project\"," + "\"base_branch\":\"--option-probe\",\"scope\":\"files\"}}}"); ASSERT_NOT_NULL(resp); - /* Without include_neighbors → NO caller_names/callee_names */ - ASSERT_NULL(strstr(resp, "\"caller_names\"")); - ASSERT_NULL(strstr(resp, "\"callee_names\"")); - /* But should still have counts */ - ASSERT_NOT_NULL(strstr(resp, "\"callers\"")); - ASSERT_NOT_NULL(strstr(resp, "\"callees\"")); + ASSERT_NOT_NULL(strstr(resp, "base_branch contains invalid characters")); free(resp); - cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); PASS(); } -/* ── TestSnippet_IncludeNeighbors_Enabled ─────────────────────── */ +/* Exercise the actual Git executable on every platform. The repository path + * contains all cmd.exe expansion/control metacharacters from the superseded + * Windows-only validator tests; the branch uses the subset Git permits in a + * ref name. Shell-backed execution either rejected or reinterpreted these + * bytes, while argv execution must preserve them literally. */ +TEST(detect_changes_handles_cmd_metacharacters_as_literal_argv) { + char base[CBM_PATH_MAX]; + char *raw = th_mktempdir("cbm_detect_literal_argv"); + ASSERT_NOT_NULL(raw); + int base_written = snprintf(base, sizeof(base), "%s", raw); + ASSERT_GT(base_written, 0); + ASSERT_LT((size_t)base_written, sizeof(base)); + + char repo[CBM_PATH_MAX]; + int repo_written = snprintf(repo, sizeof(repo), "%s/repo %%!^&; literal", base); + ASSERT_GT(repo_written, 0); + ASSERT_LT((size_t)repo_written, sizeof(repo)); + ASSERT_EQ(th_mkdir_p(repo), 0); + + const char *const init_args[] = {"init", "-q", NULL}; + const char *const email_args[] = {"config", "user.email", "test@example.com", NULL}; + const char *const name_args[] = {"config", "user.name", "Test", NULL}; + if (cbm_git_drain_command(repo, init_args) != 0 || + cbm_git_drain_command(repo, email_args) != 0 || + cbm_git_drain_command(repo, name_args) != 0) { + th_rmtree(base); + SKIP_PLATFORM("git is unavailable"); + } + char source_path[CBM_PATH_MAX]; + int source_written = snprintf(source_path, sizeof(source_path), "%s/main.c", repo); + ASSERT_GT(source_written, 0); + ASSERT_LT((size_t)source_written, sizeof(source_path)); + ASSERT_EQ(th_write_file(source_path, "int value = 1;\n"), 0); + const char *const add_args[] = {"add", "main.c", NULL}; + const char *const commit_args[] = {"commit", "-q", "-m", "initial", NULL}; + const char *const branch_args[] = {"checkout", "-q", "-b", "topic%PATH%!&;", NULL}; + ASSERT_EQ(cbm_git_drain_command(repo, add_args), 0); + ASSERT_EQ(cbm_git_drain_command(repo, commit_args), 0); + ASSERT_EQ(cbm_git_drain_command(repo, branch_args), 0); + ASSERT_EQ(th_write_file(source_path, "int value = 2;\n"), 0); -TEST(snippet_include_neighbors_enabled) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - - char *resp = - call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," - "\"include_neighbors\":true,\"project\":\"test-project\"}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"source\"")); - /* HandleRequest has 0 callers → no caller_names array */ - ASSERT_NULL(strstr(resp, "\"caller_names\"")); - /* HandleRequest has 2 callees: ProcessOrder and Run */ - ASSERT_NOT_NULL(strstr(resp, "\"callee_names\"")); - ASSERT_NOT_NULL(strstr(resp, "ProcessOrder")); - ASSERT_NOT_NULL(strstr(resp, "Run")); - free(resp); - + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "literal-argv-project", repo), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, "literal-argv-project"); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":78,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"detect_changes\"," + "\"arguments\":{\"project\":\"literal-argv-project\"," + "\"base_branch\":\"topic%PATH%!&;\",\"scope\":\"files\"}}}"); + ASSERT_NOT_NULL(response); + bool literal_base = strstr(response, "topic%PATH%!&;") != NULL; + bool changed_file = strstr(response, "main.c") != NULL; + bool validation_error = strstr(response, "invalid characters") != NULL; + if (!literal_base || !changed_file || validation_error) { + printf(" literal argv detect_changes response: %s\n", response); + } + free(response); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + ASSERT_EQ(th_rmtree(base), 0); + + ASSERT_TRUE(literal_base); + ASSERT_TRUE(changed_file); + ASSERT_FALSE(validation_error); PASS(); } -/* ── TestSnippet_SourceInvalidUtf8 ────────────────────────────── */ - -TEST(snippet_source_invalid_utf8) { - char tmp[256]; - cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); - ASSERT_NOT_NULL(srv); - - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp); - FILE *fp = fopen(src_path, "wb"); - ASSERT_NOT_NULL(fp); - const unsigned char source[] = { - 'p', 'a', 'c', 'k', 'a', 'g', 'e', ' ', 'm', 'a', 'i', 'n', '\n', '\n', - 'f', 'u', 'n', 'c', ' ', 'H', 'a', 'n', 'd', 'l', 'e', 'R', 'e', 'q', - 'u', 'e', 's', 't', '(', ')', ' ', 'e', 'r', 'r', 'o', 'r', ' ', '{', - '\n', '\t', '/', '/', ' ', 0xC0, 0xD4, 0xB7, 0xC2, '\n', '\t', 'r', 'e', 't', - 'u', 'r', 'n', ' ', 'n', 'i', 'l', '\n', '}', '\n'}; - ASSERT_EQ(fwrite(source, 1, sizeof(source), fp), sizeof(source)); - ASSERT_EQ(fclose(fp), 0); +/* Opt-in workspace boundary: when CBM_ALLOWED_ROOT is set, index_repository + * must refuse a repo_path that resolves outside it. Unset (the default) imposes + * no restriction. */ +TEST(index_repository_honors_allowed_root) { + char allowed[512]; + snprintf(allowed, sizeof(allowed), "%s/cbm_allowed_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(allowed)) { + FAIL("cbm_mkdtemp failed"); + } + cbm_setenv("CBM_ALLOWED_ROOT", allowed, 1); - char *raw = - cbm_mcp_handle_tool(srv, "get_code_snippet", - "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," - "\"project\":\"test-project\"}"); - ASSERT_TRUE(is_valid_json_response(raw)); - char *resp = extract_text_content(raw); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char args[1024]; + snprintf(args, sizeof(args), + "{\"jsonrpc\":\"2.0\",\"id\":88,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s/../..\"}}}", + allowed); /* resolves to a parent, outside the allowed root */ + char *resp = cbm_mcp_server_handle(srv, args); ASSERT_NOT_NULL(resp); - ASSERT_TRUE(is_valid_json_response(resp)); - ASSERT_NULL(strstr(resp, "\xC0\xD4")); - ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); - ASSERT_NOT_NULL(strstr(resp, "return nil")); - ASSERT_TRUE(snippet_source_has_replacement(resp)); - + ASSERT_NOT_NULL(strstr(resp, "outside the allowed root")); free(resp); - free(raw); + + cbm_unsetenv("CBM_ALLOWED_ROOT"); cbm_mcp_server_free(srv); - cleanup_snippet_dir(tmp); + cbm_rmdir(allowed); PASS(); } /* ══════════════════════════════════════════════════════════════════ - * JSON-RPC PARSING — EDGE CASES + * SUITE * ══════════════════════════════════════════════════════════════════ */ -TEST(jsonrpc_parse_empty_string) { - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse("", &req); - ASSERT_EQ(rc, -1); - cbm_jsonrpc_request_free(&req); - PASS(); -} -TEST(jsonrpc_parse_missing_jsonrpc_field) { - /* jsonrpc field absent — parser defaults to "2.0" if method present */ - const char *line = "{\"id\":1,\"method\":\"initialize\",\"params\":{}}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_STR_EQ(req.jsonrpc, "2.0"); - ASSERT_STR_EQ(req.method, "initialize"); - ASSERT_TRUE(req.has_id); - cbm_jsonrpc_request_free(&req); - PASS(); -} +#define MCP_MUTATION_GUARD_MAX_EVENTS 16 -TEST(jsonrpc_parse_missing_method) { - /* method is required — should fail */ - const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"params\":{}}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, -1); - cbm_jsonrpc_request_free(&req); - PASS(); -} +enum { + IDXFAILCLOSED_OK = 0, + IDXFAILCLOSED_NO_SERVER = 81, + IDXFAILCLOSED_PARENT_MUTATED = 82, + IDXFAILCLOSED_NO_RESPONSE = 83, + IDXFAILCLOSED_INDEXED = 84, + IDXFAILCLOSED_NOT_ERROR = 85, +}; -TEST(jsonrpc_parse_string_id) { - /* JSON-RPC §4: string and numeric ids are distinct. A string id is - * preserved verbatim (issue #253), never coerced to a number. */ - const char *line = "{\"jsonrpc\":\"2.0\",\"id\":\"99\",\"method\":\"tools/list\"}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_TRUE(req.has_id); - ASSERT_NOT_NULL(req.id_str); - ASSERT_STR_EQ(req.id_str, "99"); - ASSERT_STR_EQ(req.method, "tools/list"); - cbm_jsonrpc_request_free(&req); - PASS(); -} +enum { + IDXCANON_OK = 0, + IDXCANON_GETCWD_FAILED = 71, + IDXCANON_CHDIR_FAILED = 72, + IDXCANON_NO_SERVER = 73, + IDXCANON_CONTEXT_FAILED = 74, + IDXCANON_NO_SPAWN = 75, + IDXCANON_NO_RESULT = 76, + IDXCANON_NOT_INDEXED = 77, + IDXCANON_WRONG_PROJECT = 78, + IDXCANON_DECOY_INDEXED = 79, + IDXCANON_TARGET_MISSING = 80, + IDXCANON_CWD_RESTORE_FAILED = 81, +}; -TEST(jsonrpc_parse_no_params) { - /* Request with no params field — params_raw should be NULL */ - const char *line = "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/list\"}"; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_NULL(req.params_raw); - ASSERT_EQ(req.id, 5); - cbm_jsonrpc_request_free(&req); - PASS(); -} +/* ── Support helpers carried over from upstream main ────────────── + * Required by the upstream-only tests below; none of these names exist in + * the api-consolidation copy of this file, so no duplicate is introduced. */ -TEST(jsonrpc_parse_extra_whitespace) { - /* Leading/trailing whitespace and internal spacing in JSON */ - const char *line = " { \"jsonrpc\" : \"2.0\" , \"id\" : 7 , \"method\" : \"ping\" } "; - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_EQ(req.id, 7); - ASSERT_STR_EQ(req.method, "ping"); - cbm_jsonrpc_request_free(&req); - PASS(); -} +typedef struct { + int deny_begin_call; /* one-based; zero allows every acquisition */ + int deny_try_begin_call; /* one-based; zero allows every try acquisition */ + int cancel_on_begin_call; /* one-based; zero never requests cancellation */ + int begin_count; + int try_begin_count; + int end_count; + cbm_mcp_server_t *cancel_server; + bool cancel_attempted; + bool cancel_accepted; + const char *observed_db_path; + const char *observed_backup_path; + bool db_exists_at_begin; + bool backup_exists_at_begin; + bool db_exists_at_end; + bool backup_exists_at_end; + char begin_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; + char try_begin_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; + char end_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; +} mcp_mutation_guard_probe_t; -TEST(jsonrpc_parse_array_not_object) { - /* JSON array at root — not a valid JSON-RPC request */ - cbm_jsonrpc_request_t req = {0}; - int rc = cbm_jsonrpc_parse("[1,2,3]", &req); - ASSERT_EQ(rc, -1); - cbm_jsonrpc_request_free(&req); - PASS(); +typedef struct { + mcp_mutation_guard_probe_t guard; + const char *replacement_path; + const char *live_path; + bool replacement_attempted; + bool replacement_succeeded; +} mcp_replacing_mutation_guard_t; + +typedef struct { + const char *deny_step; + int call_count; + char steps[4][64]; +} mcp_quarantine_hook_probe_t; + +typedef struct { + bool reject_merge_base; + int diff_calls; + int status_calls; + int merge_base_calls; +} mcp_command_hook_probe_t; + +static bool mcp_quarantine_hook_probe(void *context, const char *step) { + mcp_quarantine_hook_probe_t *probe = context; + if (!probe || !step) { + return false; + } + int event = probe->call_count++; + if (event >= 0 && event < 4) { + snprintf(probe->steps[event], sizeof(probe->steps[event]), "%s", step); + } + return !probe->deny_step || strcmp(probe->deny_step, step) != 0; } -/* ══════════════════════════════════════════════════════════════════ - * ARGUMENT EXTRACTION — EDGE CASES - * ══════════════════════════════════════════════════════════════════ */ - -TEST(mcp_get_string_arg_empty_json) { - /* Empty JSON string — yyjson_read fails → NULL */ - char *val = cbm_mcp_get_string_arg("", "key"); - ASSERT_NULL(val); - PASS(); +static bool mcp_command_hook_probe(void *context, const char *command) { + mcp_command_hook_probe_t *probe = context; + if (!probe || !command) { + return false; + } + if (strstr(command, "merge-base")) { + probe->merge_base_calls++; + return !probe->reject_merge_base; + } + if (strcmp(command, "diff") == 0) { + probe->diff_calls++; + } else if (strcmp(command, "status") == 0) { + probe->status_calls++; + } else { + return false; + } + return true; } -TEST(mcp_get_string_arg_empty_object) { - /* Valid JSON with no keys → NULL for any key */ - char *val = cbm_mcp_get_string_arg("{}", "key"); - ASSERT_NULL(val); - PASS(); -} +static bool mcp_mutation_guard_probe_begin(void *context, const char *project) { + mcp_mutation_guard_probe_t *probe = context; + if (!probe) { + return false; + } -TEST(mcp_get_string_arg_nested_value) { - /* Value is an object, not a string → should return NULL */ - const char *args = "{\"config\":{\"nested\":true},\"name\":\"hello\"}"; - char *val = cbm_mcp_get_string_arg(args, "config"); - ASSERT_NULL(val); /* not a string type */ - val = cbm_mcp_get_string_arg(args, "name"); - ASSERT_NOT_NULL(val); - ASSERT_STR_EQ(val, "hello"); - free(val); - PASS(); + int event = probe->begin_count++; + if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { + snprintf(probe->begin_projects[event], sizeof(probe->begin_projects[event]), "%s", + project ? project : ""); + } + if (probe->cancel_on_begin_call > 0 && probe->begin_count == probe->cancel_on_begin_call) { + probe->cancel_attempted = true; + probe->cancel_accepted = cbm_mcp_server_cancel_active(probe->cancel_server); + } + if (probe->observed_db_path) { + probe->db_exists_at_begin = cbm_file_exists(probe->observed_db_path); + } + if (probe->observed_backup_path) { + probe->backup_exists_at_begin = cbm_file_exists(probe->observed_backup_path); + } + return probe->deny_begin_call == 0 || probe->begin_count != probe->deny_begin_call; } -TEST(mcp_get_string_arg_int_value) { - /* Value is an integer, not a string → NULL */ - char *val = cbm_mcp_get_string_arg("{\"count\":42}", "count"); - ASSERT_NULL(val); - PASS(); +static bool mcp_mutation_guard_probe_try_begin(void *context, const char *project) { + mcp_mutation_guard_probe_t *probe = context; + if (!probe) { + return false; + } + int event = probe->try_begin_count++; + if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { + snprintf(probe->try_begin_projects[event], sizeof(probe->try_begin_projects[event]), "%s", + project ? project : ""); + } + if (probe->observed_db_path) { + probe->db_exists_at_begin = cbm_file_exists(probe->observed_db_path); + } + if (probe->observed_backup_path) { + probe->backup_exists_at_begin = cbm_file_exists(probe->observed_backup_path); + } + return probe->deny_try_begin_call == 0 || probe->try_begin_count != probe->deny_try_begin_call; } -TEST(mcp_get_int_arg_empty_json) { - int val = cbm_mcp_get_int_arg("", "key", 99); - ASSERT_EQ(val, 99); - PASS(); -} +static void mcp_mutation_guard_probe_end(void *context, const char *project) { + mcp_mutation_guard_probe_t *probe = context; + if (!probe) { + return; + } -TEST(mcp_get_int_arg_string_value) { - /* Value is a string, not int → should return default */ - int val = cbm_mcp_get_int_arg("{\"limit\":\"ten\"}", "limit", 5); - ASSERT_EQ(val, 5); - PASS(); + int event = probe->end_count++; + if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { + snprintf(probe->end_projects[event], sizeof(probe->end_projects[event]), "%s", + project ? project : ""); + } + if (probe->observed_db_path) { + probe->db_exists_at_end = cbm_file_exists(probe->observed_db_path); + } + if (probe->observed_backup_path) { + probe->backup_exists_at_end = cbm_file_exists(probe->observed_backup_path); + } } -TEST(mcp_get_int_arg_bool_value) { - /* Value is a bool, not int → default */ - int val = cbm_mcp_get_int_arg("{\"flag\":true}", "flag", -1); - ASSERT_EQ(val, -1); - PASS(); +static bool mcp_make_corrupt_project_store(const char *cache, const char *project) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return false; + } + + /* A numeric root path alone is NOT a corruption trigger in this tree — it is + * the #557 COSMETIC case, which cbm_store_check_integrity_full reports via + * path_only_failure (src/store/store.c:1519-1525) so that + * resolve_store_internal RETAINS the database (src/mcp/mcp.c:4234) instead + * of quarantining it. Tests that mean "structurally corrupt" must also + * break the projects table itself, which makes the integrity check's own + * "SELECT count(*) FROM projects" fail to prepare (store.c:1473-1477) and + * leaves path_only false. The `nodes` table survives, so + * validate_cbm_db_with_timeout still admits the file as one of ours. + * + * The cosmetic half is pinned by + * tool_cosmetic_root_path_store_is_retained_not_quarantined, so both + * behaviors are locked and neither can regress silently. */ + bool created = cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK && + cbm_store_exec(store, "DROP TABLE projects;") == CBM_STORE_OK; + cbm_store_close(store); + return created; } -TEST(mcp_get_bool_arg_empty_json) { - bool val = cbm_mcp_get_bool_arg("", "key"); - ASSERT_FALSE(val); - PASS(); +static cbm_store_t *mcp_open_corrupt_project_store_with_wal(const char *cache, + const char *project) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return NULL; + } + + /* The corruption must be STRUCTURAL, not cosmetic. + * + * This fixture previously marked corruption only by writing root_path + * "826", which was sufficient upstream. In the merged tree that is + * explicitly the COSMETIC case: cbm_store_check_integrity_full + * (src/store/store.c:1501-1525) flags a root_path not starting with '/' or + * a letter, but reports it through path_only_failure, and + * resolve_store_internal (src/mcp/mcp.c:4234) then RETAINS the database + * rather than quarantining it — the #557 data-loss fix. The guarded + * quarantine arm was therefore never reached and the mutation guard never + * fired, which is what made every test using this fixture fail. + * + * Dropping `projects` makes the integrity check's own "SELECT count(*) FROM + * projects" fail to prepare (store.c:1473-1477), so it returns false with + * path_only_failure left false — genuine corruption, which is what these + * tests mean to exercise. The `nodes` table survives, so + * validate_cbm_db_with_timeout still admits the file as one of ours; the + * drop is a write, so the WAL the tests snapshot still exists. + * + * The cosmetic half is pinned separately by + * tool_cosmetic_root_path_store_is_retained_not_quarantined, so neither + * parent's behavior can regress unnoticed. */ + bool ready = + cbm_store_exec(store, "PRAGMA wal_autocheckpoint=0;") == CBM_STORE_OK && + cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK && + cbm_store_exec(store, "CREATE TABLE IF NOT EXISTS guard_wal_sentinel(value TEXT);" + "INSERT INTO guard_wal_sentinel(value) VALUES('committed');") == + CBM_STORE_OK && + cbm_store_exec(store, "DROP TABLE projects;") == CBM_STORE_OK; + if (!ready) { + cbm_store_close(store); + return NULL; + } + return store; } -TEST(mcp_get_bool_arg_int_value) { - /* Value is int 1, not bool → should return false */ - bool val = cbm_mcp_get_bool_arg("{\"flag\":1}", "flag"); - ASSERT_FALSE(val); - PASS(); +static bool mcp_make_valid_project_store_at(const char *path, const char *project, + const char *root_path) { + cbm_store_t *store = cbm_store_open_path(path); + if (!store) { + return false; + } + bool ready = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK && + cbm_store_prepare_for_publish(store) == CBM_STORE_OK; + cbm_store_close(store); + return ready; } -TEST(mcp_get_tool_name_empty_json) { - char *name = cbm_mcp_get_tool_name(""); - ASSERT_NULL(name); - PASS(); +static unsigned char *mcp_read_file_bytes(const char *path, long *out_len) { + if (!out_len) { + return NULL; + } + *out_len = 0; + FILE *fp = cbm_fopen(path, "rb"); + if (!fp || fseek(fp, 0, SEEK_END) != 0) { + if (fp) { + fclose(fp); + } + return NULL; + } + long size = ftell(fp); + if (size < 0 || fseek(fp, 0, SEEK_SET) != 0) { + fclose(fp); + return NULL; + } + unsigned char *bytes = malloc(size > 0 ? (size_t)size : SKIP_ONE); + if (!bytes) { + fclose(fp); + return NULL; + } + size_t read_count = fread(bytes, SKIP_ONE, (size_t)size, fp); + bool read_ok = read_count == (size_t)size && ferror(fp) == 0; + bool close_ok = fclose(fp) == 0; + if (!read_ok || !close_ok) { + free(bytes); + return NULL; + } + *out_len = size; + return bytes; } -TEST(mcp_get_tool_name_missing_name) { - char *name = cbm_mcp_get_tool_name("{\"arguments\":{}}"); - ASSERT_NULL(name); - PASS(); +static bool mcp_file_matches_snapshot(const char *path, const unsigned char *expected, + long expected_len) { + long actual_len = 0; + unsigned char *actual = mcp_read_file_bytes(path, &actual_len); + bool matches = actual && expected && actual_len == expected_len && + memcmp(actual, expected, (size_t)actual_len) == 0; + free(actual); + return matches; } -TEST(mcp_get_arguments_empty_json) { - char *args = cbm_mcp_get_arguments(""); - ASSERT_NULL(args); - PASS(); +static bool mcp_is_corrupt_backup_main_name(const char *name, const char *prefix) { + size_t prefix_len = strlen(prefix); + if (strcmp(name, prefix) == 0) { + return true; + } + const char *suffix = name + prefix_len; + if (strncmp(name, prefix, prefix_len) != 0 || suffix[0] != '.' || strlen(suffix + 1) != 16) { + return false; + } + for (const char *cursor = suffix + 1; *cursor; cursor++) { + if (!isxdigit((unsigned char)*cursor)) { + return false; + } + } + return true; } -TEST(mcp_get_arguments_no_arguments_key) { - /* No "arguments" key → returns "{}" */ - char *args = cbm_mcp_get_arguments("{\"name\":\"tool\"}"); - ASSERT_NOT_NULL(args); - ASSERT_STR_EQ(args, "{}"); - free(args); - PASS(); +static int mcp_find_corrupt_backups(const char *cache, const char *project, char *unique_path, + size_t unique_path_size) { + if (unique_path && unique_path_size > 0) { + unique_path[0] = '\0'; + } + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); + int count = 0; + cbm_dir_t *dir = cbm_opendir(cache); + if (!dir) { + return 0; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (!mcp_is_corrupt_backup_main_name(entry->name, prefix)) { + continue; + } + char path[CBM_SZ_1K]; + snprintf(path, sizeof(path), "%s/%s", cache, entry->name); + if (!cbm_file_exists(path)) { + continue; + } + count++; + if (unique_path && unique_path_size > 0 && unique_path[0] == '\0' && + strcmp(entry->name, prefix) != 0) { + snprintf(unique_path, unique_path_size, "%s", path); + } + } + cbm_closedir(dir); + return count; } -/* ══════════════════════════════════════════════════════════════════ - * FILE URI PARSING — EDGE CASES - * ══════════════════════════════════════════════════════════════════ */ +static int mcp_count_corrupt_artifacts(const char *cache, const char *project) { + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); + size_t prefix_len = strlen(prefix); + int count = 0; + cbm_dir_t *dir = cbm_opendir(cache); + if (!dir) { + return 0; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strncmp(entry->name, prefix, prefix_len) == 0) { + count++; + } + } + cbm_closedir(dir); + return count; +} -TEST(parse_file_uri_http_scheme) { - char path[256]; - ASSERT_FALSE(cbm_parse_file_uri("http://example.com/path", path, sizeof(path))); - ASSERT_STR_EQ(path, ""); - PASS(); +static int mcp_count_directory_entries_with_prefix(const char *directory, const char *prefix) { + cbm_dir_t *dir = cbm_opendir(directory); + if (!dir) { + return -1; + } + size_t prefix_length = strlen(prefix); + int count = 0; + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strncmp(entry->name, prefix, prefix_length) == 0) { + count++; + } + } + cbm_closedir(dir); + return count; } -TEST(parse_file_uri_ftp_scheme) { - char path[256]; - ASSERT_FALSE(cbm_parse_file_uri("ftp://server/file.txt", path, sizeof(path))); - ASSERT_STR_EQ(path, ""); - PASS(); +static void mcp_cleanup_corrupt_backups(const char *cache, const char *project) { + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); + size_t prefix_len = strlen(prefix); + cbm_dir_t *dir = cbm_opendir(cache); + if (!dir) { + return; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strncmp(entry->name, prefix, prefix_len) == 0) { + char path[CBM_SZ_1K]; + snprintf(path, sizeof(path), "%s/%s", cache, entry->name); + cbm_unlink(path); + } + } + cbm_closedir(dir); } -TEST(parse_file_uri_buffer_too_small) { - char path[5]; /* only 5 bytes — path gets truncated */ - ASSERT_TRUE(cbm_parse_file_uri("file:///usr/local/bin", path, sizeof(path))); - /* snprintf truncates to 4 chars + NUL */ - ASSERT_EQ(strlen(path), 4); - ASSERT_STR_EQ(path, "/usr"); - PASS(); +static bool mcp_replacing_mutation_guard_publish(mcp_replacing_mutation_guard_t *replacement) { + replacement->replacement_attempted = true; + bool sidecars_removed = cbm_remove_db_sidecars(replacement->live_path) == 0; + replacement->replacement_succeeded = + sidecars_removed && + cbm_rename_replace(replacement->replacement_path, replacement->live_path) == 0; + return true; } -TEST(parse_file_uri_spaces_in_path) { - char path[256]; - ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/my%20project", path, sizeof(path))); - /* Raw percent-encoding is preserved (not decoded) */ - ASSERT_STR_EQ(path, "/home/user/my%20project"); - PASS(); +static bool mcp_replacing_mutation_guard_begin(void *context, const char *project) { + mcp_replacing_mutation_guard_t *replacement = context; + return replacement && mcp_mutation_guard_probe_begin(&replacement->guard, project) && + mcp_replacing_mutation_guard_publish(replacement); } -TEST(parse_file_uri_null_out_path) { - /* NULL out_path — should not crash */ - ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", NULL, 256)); - PASS(); +static void mcp_replacing_mutation_guard_end(void *context, const char *project) { + mcp_replacing_mutation_guard_t *replacement = context; + if (replacement) { + mcp_mutation_guard_probe_end(&replacement->guard, project); + } } -TEST(parse_file_uri_zero_size) { - char path[256] = "garbage"; - /* out_size=0 → should fail safely */ - ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", path, 0)); - PASS(); +static bool mcp_cross_repo_create_project_store(const char *cache, const char *project, + const char *root_path) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return false; + } + bool created = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK; + cbm_store_close(store); + return created; } -/* ══════════════════════════════════════════════════════════════════ - * SERVER HANDLE — EDGE CASES - * ══════════════════════════════════════════════════════════════════ */ +static bool mcp_cross_repo_seed_http_match(const char *cache, const char *source_project, + const char *target_project, const char *root_path) { + char source_path[CBM_SZ_1K]; + char target_path[CBM_SZ_1K]; + snprintf(source_path, sizeof(source_path), "%s/%s.db", cache, source_project); + snprintf(target_path, sizeof(target_path), "%s/%s.db", cache, target_project); -TEST(server_handle_invalid_json) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *source = cbm_store_open_path(source_path); + cbm_store_t *target = cbm_store_open_path(target_path); + if (!source || !target) { + cbm_store_close(source); + cbm_store_close(target); + return false; + } - char *resp = cbm_mcp_server_handle(srv, "this is not json at all"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"error\"")); - ASSERT_NOT_NULL(strstr(resp, "-32700")); /* Parse error */ - free(resp); + bool ok = cbm_store_upsert_project(source, source_project, root_path) == CBM_STORE_OK && + cbm_store_upsert_project(target, target_project, root_path) == CBM_STORE_OK; - cbm_mcp_server_free(srv); - PASS(); -} + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = "call_once", + .qualified_name = "cross.source.call_once", + .file_path = "client.c", + .start_line = 1, + .end_line = 2}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = "GET /dedupe", + .qualified_name = "__route__GET__/dedupe", + .file_path = "client.c", + .start_line = 3, + .end_line = 3}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = "{\"url_path\":\"/dedupe\",\"method\":\"GET\"}"}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; -TEST(server_handle_empty_object) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = "GET /dedupe", + .qualified_name = "__route__GET__/dedupe", + .file_path = "server.c", + .start_line = 3, + .end_line = 3}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = "handle_once", + .qualified_name = "cross.target.handle_once", + .file_path = "server.c", + .start_line = 1, + .end_line = 2}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; - /* Valid JSON but no method field → parse error */ - char *resp = cbm_mcp_server_handle(srv, "{}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"error\"")); - free(resp); + cbm_store_close(source); + cbm_store_close(target); + return ok; +} - cbm_mcp_server_free(srv); - PASS(); +static unsigned char mcp_test_ascii_casefold(unsigned char ch) { + return ch >= 'A' && ch <= 'Z' ? (unsigned char)(ch + ('a' - 'A')) : ch; } -TEST(server_handle_tools_call_missing_name) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); +static bool mcp_test_project_keys_equivalent(const char *left, const char *right) { + if (!left || !right) { + return left == right; + } + while (*left && *right) { + if (mcp_test_ascii_casefold((unsigned char)*left) != + mcp_test_ascii_casefold((unsigned char)*right)) { + return false; + } + left++; + right++; + } + return *left == *right; +} - /* tools/call with no tool name in params */ - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," - "\"params\":{\"arguments\":{}}}"); - ASSERT_NOT_NULL(resp); - /* Should return error about unknown/missing tool */ - ASSERT_NOT_NULL(strstr(resp, "\"id\":50")); - ASSERT_TRUE(strstr(resp, "error") || strstr(resp, "isError") || strstr(resp, "unknown")); - free(resp); +int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, const char *cache_dir) { + (void)cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_index_supervisor_mark_host(); + (void)cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); - cbm_mcp_server_free(srv); - PASS(); -} + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) { + return IDXFAILCLOSED_NO_SERVER; + } + mcp_mutation_guard_probe_t parent_guard = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &parent_guard); -/* ══════════════════════════════════════════════════════════════════ - * POLL/GETLINE FILE* BUFFERING FIX - * ══════════════════════════════════════════════════════════════════ */ + char args[CBM_SZ_4K]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir); + char *response = cbm_mcp_handle_tool(srv, "index_repository", args); -#ifndef _WIN32 -#include -#include + int result = IDXFAILCLOSED_OK; + if (parent_guard.begin_count != 0 || parent_guard.end_count != 0) { + result = IDXFAILCLOSED_PARENT_MUTATED; + } else if (!response) { + result = IDXFAILCLOSED_NO_RESPONSE; + } else if (response_contains_json_fragment(response, "\"status\":\"indexed\"")) { + result = IDXFAILCLOSED_INDEXED; + } else if (!response_contains_json_fragment(response, "\"status\":\"error\"") || + !response_contains_json_fragment(response, "\"outcome\":\"spawn_failed\"")) { + result = IDXFAILCLOSED_NOT_ERROR; + } -/* Signal handler used by alarm() to abort the test if it hangs */ -static void alarm_handler(int sig) { - (void)sig; - /* Writing to stderr is async-signal-safe */ - const char msg[] = "FAIL: mcp_server_run_rapid_messages timed out (>5s)\n"; - write(STDERR_FILENO, msg, sizeof(msg) - 1); - _exit(1); + free(response); + cbm_mcp_server_free(srv); + return result; } -TEST(mcp_server_run_rapid_messages) { - /* Simulate a client sending initialize + notifications/initialized + - * tools/list all at once (no delays), which exercises the FILE* - * buffering fix: the first getline() over-reads kernel data into the - * libc buffer; without the fix, subsequent poll() calls block for 60s. - * - * We use alarm(5) to abort the test process if the server hangs. */ - int fds[2]; - ASSERT_EQ(pipe(fds), 0); +#ifndef _WIN32 /* helpers used only by the POSIX fork/spawn tests below */ +static bool idxfailclosed_self_path(char out[CBM_SZ_4K]) { +#ifdef __APPLE__ + int length = proc_pidpath(getpid(), out, CBM_SZ_4K); + bool resolved = length > 0 && length < CBM_SZ_4K; + if (resolved) { + out[length] = '\0'; + } + return resolved; +#elif defined(__linux__) + ssize_t length = readlink("/proc/self/exe", out, CBM_SZ_4K - 1); + bool resolved = length > 0 && length < (ssize_t)CBM_SZ_4K - 1; + if (resolved) { + out[length] = '\0'; + } + return resolved; +#else + (void)out; + return false; +#endif +} - /* Write all 3 messages to the write end in one shot */ - const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," - "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n" - "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" - "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; - ssize_t written = write(fds[1], msgs, strlen(msgs)); - ASSERT_TRUE(written > 0); - close(fds[1]); /* EOF signals end of input to the server */ +static int idxcanon_supervised_session_path_check(const char *session_root, const char *decoy_cwd) { + char saved_cwd[CBM_SZ_4K]; + if (!cbm_getcwd(saved_cwd, sizeof(saved_cwd))) { + return IDXCANON_GETCWD_FAILED; + } + if (cbm_chdir(decoy_cwd) != 0) { + return IDXCANON_CHDIR_FAILED; + } - FILE *in_fp = fdopen(fds[0], "r"); - ASSERT_NOT_NULL(in_fp); + /* Match a real supervisor host. Environment changes are isolated to this + * forked child and inherited by its worker; the parent test process keeps + * its supervisor kill switch and allowed-root environment untouched. */ + cbm_index_supervisor_mark_host(); + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + cbm_unsetenv("CBM_ALLOWED_ROOT"); + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); + cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); - FILE *out_fp = tmpfile(); - ASSERT_NOT_NULL(out_fp); + char session_repo[CBM_SZ_4K]; + char decoy_repo[CBM_SZ_4K]; + snprintf(session_repo, sizeof(session_repo), "%s/repo", session_root); + snprintf(decoy_repo, sizeof(decoy_repo), "%s/repo", decoy_cwd); + char *session_project = cbm_project_name_from_path(session_repo); + char *decoy_project = cbm_project_name_from_path(decoy_repo); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - - /* Install alarm to fail the test if cbm_mcp_server_run blocks */ - signal(SIGALRM, alarm_handler); - alarm(5); + int code = IDXCANON_OK; + if (!srv) { + code = IDXCANON_NO_SERVER; + } else if (!cbm_mcp_server_set_session_context(srv, session_root, session_root)) { + code = IDXCANON_CONTEXT_FAILED; + } - int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + int spawns_before = cbm_index_supervisor_spawn_count(); + char *resp = code == IDXCANON_OK + ? cbm_mcp_handle_tool(srv, "index_repository", + "{\"repo_path\":\"repo\",\"mode\":\"fast\"}") + : NULL; + int spawns_after = cbm_index_supervisor_spawn_count(); + if (code == IDXCANON_OK && spawns_after == spawns_before) { + code = IDXCANON_NO_SPAWN; + } else if (code == IDXCANON_OK && !resp) { + code = IDXCANON_NO_RESULT; + } else if (code == IDXCANON_OK && + !response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + code = IDXCANON_NOT_INDEXED; + } - alarm(0); /* cancel alarm */ - signal(SIGALRM, SIG_DFL); + if (code == IDXCANON_OK) { + char expected[CBM_SZ_4K]; + snprintf(expected, sizeof(expected), "\"project\":\"%s\"", + session_project ? session_project : ""); + if (!session_project || !response_contains_json_fragment(resp, expected)) { + code = IDXCANON_WRONG_PROJECT; + } + } + free(resp); - ASSERT_EQ(rc, 0); + /* A raw "repo" handoff is interpreted relative to decoy_cwd by the worker + * and creates this project DB. Its absence proves the original JSON did not + * substitute a different path after the parent validated session_repo. */ + if (code == IDXCANON_OK) { + const char *cache = getenv("CBM_CACHE_DIR"); + char decoy_db[CBM_SZ_4K]; + snprintf(decoy_db, sizeof(decoy_db), "%s/%s.db", cache ? cache : "", + decoy_project ? decoy_project : ""); + if (!cache || !decoy_project || cbm_file_size(decoy_db) >= 0) { + code = IDXCANON_DECOY_INDEXED; + } + } - /* Verify both responses are present: - * id:1 — initialize response - * id:2 — tools/list response (notifications/initialized produces none) - * and that the tools list payload is included. */ - rewind(out_fp); - char buf[4096] = {0}; - size_t nread = fread(buf, 1, sizeof(buf) - 1, out_fp); - ASSERT_TRUE(nread > 0); - ASSERT_NOT_NULL(strstr(buf, "\"id\":1")); - ASSERT_NOT_NULL(strstr(buf, "\"id\":2")); - ASSERT_NOT_NULL(strstr(buf, "tools")); + if (code == IDXCANON_OK) { + char query[CBM_SZ_4K]; + snprintf(query, sizeof(query), + "{\"project\":\"%s\",\"name_pattern\":\"canonical_target_fn\"," + "\"label\":\"Function\"}", + session_project ? session_project : ""); + char *search = cbm_mcp_handle_tool(srv, "search_graph", query); + if (!session_project || !search || !strstr(search, "canonical_target_fn")) { + code = IDXCANON_TARGET_MISSING; + } + free(search); + } cbm_mcp_server_free(srv); - fclose(out_fp); - /* in_fp already EOF; fclose cleans up */ - fclose(in_fp); - PASS(); + free(session_project); + free(decoy_project); + if (cbm_chdir(saved_cwd) != 0 && code == IDXCANON_OK) { + code = IDXCANON_CWD_RESTORE_FAILED; + } + return code; } #endif /* !_WIN32 */ -/* Issue #235: passing an unrecognised project name to a tool crashed the - * binary with a buffer overflow while building the "available_projects" - * error list — collect_db_project_names overflowed projects[CBM_SZ_4K] via - * an unsigned underflow on (out_sz - offset) once the listed names exceeded - * the buffer. Fill a temp cache dir with enough long-named .db files to - * exceed 4 KB, then hit the bad-project path. Under ASan a regression aborts - * here; the fixed bounds-check keeps it clean and returns a normal error. */ -#define ISSUE235_DBNAME(buf, dir, i) \ - snprintf((buf), sizeof(buf), \ - "%s/proj_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \ - (dir), (i)) -TEST(tool_bad_project_name_no_overflow_issue235) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); /* skip if mkdtemp fails */ - } +/* ── Tests carried over from upstream main ────────────────────────── + * Upstream-only coverage: cross-repo mutation guards and lease cancellation, + * corrupt-store cleanup, request-scope cancellation, index-supervisor + * fail-closed behavior, and Windows cmd metacharacter rejection. */ - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); +TEST(tool_search_graph_toon_never_leaks_internal_fields) { + /* The similarity/semantic pipeline intermediates (fp minhash hex, sp + * structural profile, bt body-token bag) dominated the legacy payload + * (~45%) and carry zero agent value. GUARD: they never appear in TOON + * output — not by default and not even when explicitly requested via + * fields (blocklist). */ + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); - /* 40 * ~120-char names overflows the 4 KB available-projects buffer. - * collect_db_project_names advertises each db's INTERNAL project name - * (#704), so the fixture must hold valid dbs with long internal names — - * not stub files — for the bounds-check path to actually be exercised. */ - enum { ISSUE235_N = 40 }; - for (int i = 0; i < ISSUE235_N; i++) { - char name[512]; - ISSUE235_DBNAME(name, cache, i); - char iname[256]; - snprintf(iname, sizeof(iname), - "proj_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - i); - cbm_store_t *st = cbm_store_open_path(name); - if (st) { - cbm_store_upsert_project(st, iname, cache); - cbm_store_close(st); - } - } + /* A node whose properties carry the internal fields with sentinels. */ + cbm_node_t n = {0}; + n.project = "test-project"; + n.label = "Function"; + n.name = "fpCarrier"; + n.qualified_name = "test-project.src.fpCarrier"; + n.file_path = "src/fp.go"; + n.start_line = 1; + n.end_line = 2; + n.properties_json = "{\"fp\":\"FPSENTINEL00\",\"sp\":\"SPSENTINEL00\"," + "\"bt\":\"BTSENTINEL00\",\"complexity\":7}"; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":" - "\"search_graph\",\"arguments\":{\"label\":\"Function\"," - "\"project\":\"definitely-not-a-real-project-xyz\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":45,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"test-project\",\"name_pattern\":\"fpCarrier\"," + "\"fields\":[\"fp\",\"sp\",\"bt\",\"complexity\"],\"limit\":5}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "not found")); - free(resp); - cbm_mcp_server_free(srv); - - if (saved_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); - free(saved_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); - } - for (int i = 0; i < ISSUE235_N; i++) { - char name[512]; - ISSUE235_DBNAME(name, cache, i); - cbm_unlink(name); - char side[540]; - snprintf(side, sizeof(side), "%s-wal", name); - cbm_unlink(side); - snprintf(side, sizeof(side), "%s-shm", name); - cbm_unlink(side); - } - cbm_rmdir(cache); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "fpCarrier")); + ASSERT_NULL(strstr(inner, "FPSENTINEL00")); + ASSERT_NULL(strstr(inner, "SPSENTINEL00")); + ASSERT_NULL(strstr(inner, "BTSENTINEL00")); + /* Non-blocked requested field still comes through. */ + ASSERT_NOT_NULL(strstr(inner, "complexity")); + free(inner); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); PASS(); } -#undef ISSUE235_DBNAME -/* Issue #235 (follow-up): with many long-named projects indexed, - * collect_db_project_names overflowed projects[CBM_SZ_4K] and truncated the - * LAST name MID-TOKEN, then clamped offset to out_sz-1 — emitting malformed, - * unterminated JSON like - * ...,"available_projects":["a",...,"vjson_49_bbb],"count":50} - * (unclosed string + unclosed array). build_project_list_error wrapped that - * invalid body into the tool error, so a "project not found" reply was NOT - * valid JSON once enough projects were indexed. - * - * Reproduce-first: fill an isolated cache dir with enough long INTERNAL-named - * dbs to overflow the 4 KB buffer, hit the bad-project path, then assert the - * ERROR BODY (the inner MCP text content) parses as valid JSON and that - * available_projects is a JSON array whose length == count. RED on the - * truncating code (yyjson_read returns NULL on the mid-token cut); GREEN after - * the element-boundary fix, which only ever writes whole "name" tokens. */ -#define BADPROJ_JSON_DBNAME(buf, dir, i) \ - snprintf((buf), sizeof(buf), \ - "%s/vjson_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \ - (dir), (i)) -TEST(tool_bad_project_error_valid_json_issue235) { - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-vjson-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - PASS(); /* skip if mkdtemp fails */ - } +TEST(tool_trace_call_path_not_found) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\"," + "\"arguments\":{\"function_name\":\"NonExistent\"," + "\"project\":\"nonexistent\"}}}"); + ASSERT_NOT_NULL(resp); + /* Should return error about project not found */ + ASSERT_NOT_NULL(strstr(resp, "not found")); + free(resp); - /* 50 * ~120-char INTERNAL names >> 4 KB → the available_projects buffer - * overflows and the last name is cut mid-token on the unfixed code. */ - enum { BADPROJ_N = 50 }; - for (int i = 0; i < BADPROJ_N; i++) { - char name[512]; - BADPROJ_JSON_DBNAME(name, cache, i); - char iname[256]; - snprintf(iname, sizeof(iname), - "vjson_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - i); - cbm_store_t *st = cbm_store_open_path(name); - if (st) { - cbm_store_upsert_project(st, iname, cache); - cbm_store_close(st); - } - } + cbm_mcp_server_free(srv); + PASS(); +} +/* Regression: two same-named definitions with equal rank must be reported + * ambiguous, not silently traced (trace_path previously took nodes[0]). */ +TEST(tool_trace_call_path_ambiguous) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "amb-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/amb"); + cbm_node_t a = {.project = proj, + .label = "Function", + .name = "amb", + .qualified_name = "amb-proj.a.amb", + .file_path = "a.c", + .start_line = 10, + .end_line = 20}; + cbm_node_t b = {.project = proj, + .label = "Function", + .name = "amb", + .qualified_name = "amb-proj.b.amb", + .file_path = "b.c", + .start_line = 10, + .end_line = 20}; /* equal span -> genuine tie */ + ASSERT_GT(cbm_store_upsert_node(st, &a), 0); + ASSERT_GT(cbm_store_upsert_node(st, &b), 0); + char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":" - "\"search_graph\",\"arguments\":{\"label\":\"Function\"," - "\"project\":\"definitely-not-a-real-project-xyz\"}}}"); + srv, "{\"jsonrpc\":\"2.0\",\"id\":61,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\"," + "\"arguments\":{\"function_name\":\"amb\",\"project\":\"amb-proj\"}}}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "not found")); - - /* The inner MCP text content is the error body built by - * build_project_list_error. Capture its validity BEFORE cleanup so a RED - * failure still restores the environment. */ - char *body = extract_text_content(resp); - bool body_valid = false; - bool aps_ok = false; /* available_projects is an array whose len == count */ - if (body) { - yyjson_doc *bdoc = yyjson_read(body, strlen(body), 0); - if (bdoc) { - body_valid = true; - yyjson_val *broot = yyjson_doc_get_root(bdoc); - yyjson_val *aps = yyjson_obj_get(broot, "available_projects"); - yyjson_val *cnt = yyjson_obj_get(broot, "count"); - if (aps && yyjson_is_arr(aps) && cnt && yyjson_is_int(cnt)) { - aps_ok = (yyjson_arr_size(aps) == (size_t)yyjson_get_int(cnt)); - } - yyjson_doc_free(bdoc); - } - } - free(body); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "ambiguous")); + ASSERT_NOT_NULL(strstr(inner, "suggestions")); + ASSERT_NULL(strstr(inner, "\"callees\"")); + free(inner); free(resp); cbm_mcp_server_free(srv); - - if (saved_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); - free(saved_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); - } - for (int i = 0; i < BADPROJ_N; i++) { - char name[512]; - BADPROJ_JSON_DBNAME(name, cache, i); - cbm_unlink(name); - char side[540]; - snprintf(side, sizeof(side), "%s-wal", name); - cbm_unlink(side); - snprintf(side, sizeof(side), "%s-shm", name); - cbm_unlink(side); - } - cbm_rmdir(cache); - - /* RED on the unfixed code: mid-token truncation → invalid JSON body. */ - ASSERT_TRUE(body_valid); - ASSERT_TRUE(aps_ok); PASS(); } -#undef BADPROJ_JSON_DBNAME -/* ── #704: project resolution must key on the db's INTERNAL project name ── - * - * Issue #704: project resolution is registry-less and filename-addressed. - * resolve_store() opens /.db and then requires the internal - * `projects.name` row to equal the passed name; list_projects / - * collect_db_project_names derive the advertised name from the .db FILENAME. - * When a db's filename != its internal name (a legacy '.'-vs-'-' username - * twin, or a copied/renamed file) it shows up in list_projects under the - * filename, but every query returns "project not found" — node rows are - * tagged with the INTERNAL name, so neither the filename nor the resolve - * path lines up. The fix makes list + resolve both key on the INTERNAL name. - * - * Reproduce-first fixture in an isolated CBM_CACHE_DIR: - * - alpha704.db : filename == internal name "alpha704" (control / fast path) - * - gamma704.db : internal name "beta704" (DRIFT: built as - * beta704.db then renamed → filename != internal name) - * - ghost704.db : 0-byte file (ghost / unresolvable) - * - * RED on buggy code / GREEN on the fix: - * A. list_projects advertises "beta704" (internal), NOT "gamma704" (filename), - * and NOT "ghost704" (0-byte filtered). - * B. search_graph(project="beta704") resolves via the cache-dir scan and - * returns the node — not the "project not found" error. - * C. control project "alpha704" still resolves on the fast path. - * D. the 0-byte ghost is not resolvable. - * E. addressing the drifted db by its FILENAME ("gamma704") stays not-found - * (we key on the internal name, never the file on disk). - */ +/* Regression: when same-named nodes differ in rank, trace must pick the real + * definition (callable, larger body) — NOT nodes[0]. The Module is inserted + * first; if trace took nodes[0] the outbound trace would be empty. */ +TEST(tool_trace_call_path_prefers_definition) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "pref-proj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/pref"); + /* nodes[0]: the WRONG match (a Module, tiny span), inserted first. */ + cbm_node_t wrong = {.project = proj, + .label = "Module", + .name = "dup", + .qualified_name = "pref-proj.dup", + .file_path = "dup.x", + .start_line = 1, + .end_line = 1}; + /* the real definition: a Function with a body. */ + cbm_node_t def = {.project = proj, + .label = "Function", + .name = "dup", + .qualified_name = "pref-proj.src.dup", + .file_path = "src/dup.c", + .start_line = 10, + .end_line = 50}; + cbm_node_t callee = {.project = proj, + .label = "Function", + .name = "callee", + .qualified_name = "pref-proj.src.callee", + .file_path = "src/dup.c", + .start_line = 60, + .end_line = 70}; + ASSERT_GT(cbm_store_upsert_node(st, &wrong), 0); + int64_t id_def = cbm_store_upsert_node(st, &def); + int64_t id_callee = cbm_store_upsert_node(st, &callee); + ASSERT_GT(id_def, 0); + ASSERT_GT(id_callee, 0); + cbm_edge_t e = {.project = proj, .source_id = id_def, .target_id = id_callee, .type = "CALLS"}; + cbm_store_insert_edge(st, &e); -/* Create a file-backed project db at / whose INTERNAL project - * name is `internal` (which may differ from the filename), holding one - * Function node named `fn`. Returns true on success. */ -static bool issue704_make_db(const char *dir, const char *filename, const char *internal, - const char *fn) { - char path[700]; - snprintf(path, sizeof(path), "%s/%s", dir, filename); - cbm_store_t *st = cbm_store_open_path(path); - if (!st) { - return false; - } - bool ok = (cbm_store_upsert_project(st, internal, dir) == CBM_STORE_OK); - if (ok) { - char qn[256]; - snprintf(qn, sizeof(qn), "%s.%s", internal, fn); - cbm_node_t n = {0}; - n.project = internal; - n.label = "Function"; - n.name = fn; - n.qualified_name = qn; - n.file_path = "main.go"; - n.start_line = 1; - n.end_line = 2; - ok = (cbm_store_upsert_node(st, &n) > 0); - } - cbm_store_close(st); - return ok; + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dup\"," + "\"project\":\"pref-proj\",\"direction\":\"outbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "ambiguous")); + /* picked the Function definition -> its outbound CALLS edge to "callee" shows */ + ASSERT_NOT_NULL(strstr(inner, "callee")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); } -TEST(tool_resolve_store_by_internal_name_issue704) { +TEST(tool_delete_project_mutation_guard_blocks_then_releases) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-issue704-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-guard-XXXXXX"); if (!cbm_mkdtemp(cache)) { - PASS(); /* skip if mkdtemp fails — not a #704 signal */ + PASS(); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - /* (1) control: filename == internal name */ - ASSERT_TRUE(issue704_make_db(cache, "alpha704.db", "alpha704", "alphaFunc704")); + const char *project = "guard-delete-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/guard-delete-project"), CBM_STORE_OK); + cbm_store_close(setup); + ASSERT_TRUE(cbm_file_exists(db_path)); - /* (2) DRIFT: build beta704.db (internal "beta704") then rename the file to - * gamma704.db, so filename "gamma704" != internal "beta704". */ - ASSERT_TRUE(issue704_make_db(cache, "beta704.db", "beta704", "betaFunc704")); - char beta_path[700]; - char gamma_path[700]; - snprintf(beta_path, sizeof(beta_path), "%s/beta704.db", cache); - snprintf(gamma_path, sizeof(gamma_path), "%s/gamma704.db", cache); - ASSERT_EQ(rename(beta_path, gamma_path), 0); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - /* (3) ghost: 0-byte db file */ - char ghost_path[700]; - snprintf(ghost_path, sizeof(ghost_path), "%s/ghost704.db", cache); - FILE *gp = fopen(ghost_path, "w"); - ASSERT_NOT_NULL(gp); - fclose(gp); + char *resp = + cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "blocked")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 0); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_TRUE(cbm_file_exists(db_path)); + free(resp); + + probe.deny_begin_call = 0; + resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + ASSERT_EQ(probe.begin_count, 2); + ASSERT_EQ(probe.end_count, 1); + ASSERT_STR_EQ(probe.begin_projects[1], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + ASSERT_FALSE(cbm_file_exists(db_path)); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + +TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { + char root[CBM_SZ_1K]; + (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - /* ── A: list_projects reports INTERNAL names; filters the ghost ── */ - char *list = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); - ASSERT_NOT_NULL(list); - ASSERT_NOT_NULL(strstr(list, "alpha704")); /* control */ - ASSERT_NOT_NULL(strstr(list, "beta704")); /* internal name of drifted db (RED before) */ - ASSERT_NULL(strstr(list, "gamma704")); /* filename must NOT be advertised (RED before) */ - ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */ - free(list); + char args[CBM_SZ_2K]; + (void)snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"name\":\"GuardedIndex\"," + "\"mode\":\"fast\"}", + root); + int spawn_before = cbm_index_supervisor_spawn_count(); + char *response = cbm_mcp_handle_tool(srv, "index_repository", args); + int spawn_after = cbm_index_supervisor_spawn_count(); - /* ── B: the drifted project resolves by its INTERNAL name ──────── */ - char *q_beta = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\",\"arguments\":{" - "\"project\":\"beta704\",\"name_pattern\":\"betaFunc704\",\"limit\":5}}}"); - ASSERT_NOT_NULL(q_beta); - ASSERT_NOT_NULL(strstr(q_beta, "betaFunc704")); /* resolved + returned node (RED before) */ - ASSERT_NULL(strstr(q_beta, "not found")); /* not the not-found error */ - free(q_beta); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "blocked")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 0); + ASSERT_STR_EQ(probe.begin_projects[0], "GuardedIndex"); + ASSERT_EQ(spawn_after, spawn_before); - /* ── C: control project still resolves on the fast path ────────── */ - char *q_alpha = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\",\"arguments\":{" - "\"project\":\"alpha704\",\"name_pattern\":\"alphaFunc704\",\"limit\":5}}}"); - ASSERT_NOT_NULL(q_alpha); - ASSERT_NOT_NULL(strstr(q_alpha, "alphaFunc704")); - free(q_alpha); + free(response); + cbm_mcp_server_free(srv); + (void)th_rmtree(root); + PASS(); +} - /* ── D: the 0-byte ghost is NOT resolvable ─────────────────────── */ - char *q_ghost = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\",\"arguments\":{" - "\"project\":\"ghost704\",\"name_pattern\":\".*\",\"limit\":5}}}"); - ASSERT_NOT_NULL(q_ghost); - ASSERT_NOT_NULL(strstr(q_ghost, "not found")); - free(q_ghost); +TEST(tool_manage_adr_rejects_removed_sections_argument) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + ASSERT_EQ(cbm_store_upsert_project(st, "adr-sections-guard", "/tmp/adr-sections-guard"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, "adr-sections-guard"); + ASSERT_EQ(cbm_store_adr_store(st, "adr-sections-guard", "## PURPOSE\nOriginal ADR.\n"), + CBM_STORE_OK); - /* ── E: addressing the drifted db by its FILENAME stays not-found ── */ - char *q_gamma = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\",\"arguments\":{" - "\"project\":\"gamma704\",\"name_pattern\":\".*\",\"limit\":5}}}"); - ASSERT_NOT_NULL(q_gamma); - ASSERT_NOT_NULL(strstr(q_gamma, "not found")); - free(q_gamma); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":122,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\",\"arguments\":{" + "\"project\":\"adr-sections-guard\",\"mode\":\"update\"," + "\"sections\":[\"PURPOSE\"],\"content\":\"## PURPOSE\\nReplacement ADR.\\n\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "unknown argument 'sections'")); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + free(resp); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.end_count, 0); + + cbm_adr_t adr; + memset(&adr, 0, sizeof(adr)); + ASSERT_EQ(cbm_store_adr_get(st, "adr-sections-guard", &adr), CBM_STORE_OK); + ASSERT_STR_EQ(adr.content, "## PURPOSE\nOriginal ADR.\n"); + cbm_store_adr_free(&adr); cbm_mcp_server_free(srv); + PASS(); +} - /* ── cleanup ───────────────────────────────────────────────────── */ - if (saved_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); - free(saved_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); - } - char a_path[700]; - snprintf(a_path, sizeof(a_path), "%s/alpha704.db", cache); - cbm_unlink(a_path); - cbm_unlink(gamma_path); - cbm_unlink(ghost_path); - mcp_cleanup_corrupt_backups(cache, "ghost704"); - char side[740]; - snprintf(side, sizeof(side), "%s-wal", a_path); - cbm_unlink(side); - snprintf(side, sizeof(side), "%s-shm", a_path); - cbm_unlink(side); - snprintf(side, sizeof(side), "%s-wal", gamma_path); - cbm_unlink(side); - snprintf(side, sizeof(side), "%s-shm", gamma_path); - cbm_unlink(side); - cbm_rmdir(cache); +TEST(tool_manage_adr_mutation_guard_balances_success) { + const char *project = "guard-adr-success"; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-success"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + + char *resp = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"guard-adr-success\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nGuarded ADR.\\n\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 1); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + free(resp); + + cbm_mcp_server_free(srv); PASS(); } -/* ── #1044: a "::missed" shadow row must not hide the project ── - * - * The miss-graph pass inserts a second `projects` row ("::missed") so - * its nodes satisfy the FK on nodes.project. db_internal_project_name - * required the projects table to hold EXACTLY ONE row, so any project with - * a miss graph vanished from list_projects and the graph UI, and the - * fallback-scan resolve path failed. - * - * RED on buggy code / GREEN on the fix: - * A. list_projects still advertises "delta1044" while the shadow row exists. - * B. the shadow name itself is never advertised. - * C. search_graph(project="delta1044") still resolves and returns the node. - */ -TEST(tool_list_projects_ignores_missed_shadow_issue1044) { +/* ADR reads use the current SQLite snapshot and must not wait behind a + * potentially long-running index mutation. This keeps read latency O(read) + * instead of adding unbounded mutation-queue latency, without changing the + * query's O(result bytes) output memory or the underlying store lookup cost. */ +TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard) { + const char *project = "guard-adr-read"; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-read"), CBM_STORE_OK); + ASSERT_EQ( + cbm_store_adr_store(store, project, "## PURPOSE\nNonblocking read.\n\n## STACK\nC.\n"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + + char *get_response = + cbm_mcp_handle_tool(srv, "manage_adr", "{\"project\":\"guard-adr-read\",\"mode\":\"get\"}"); + char *sections_response = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-read\",\"mode\":\"sections\"}"); + bool get_returned_adr = get_response && strstr(get_response, "Nonblocking read.") && + !strstr(get_response, "\"isError\":true"); + bool sections_returned_adr = sections_response && strstr(sections_response, "## PURPOSE") && + strstr(sections_response, "## STACK") && + !strstr(sections_response, "\"isError\":true"); + + free(get_response); + free(sections_response); + cbm_mcp_server_free(srv); + + ASSERT_TRUE(get_returned_adr); + ASSERT_TRUE(sections_returned_adr); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.end_count, 0); + PASS(); +} + +TEST(tool_manage_adr_read_missing_store_skips_mutation_guard) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-issue1044-XXXXXX"); + snprintf(cache, sizeof(cache), "%s/cbm-mcp-adr-guard-XXXXXX", cbm_tmpdir()); if (!cbm_mkdtemp(cache)) { - PASS(); /* skip if mkdtemp fails — not a #1044 signal */ + PASS(); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - ASSERT_TRUE(issue704_make_db(cache, "delta1044.db", "delta1044", "deltaFunc1044")); - - /* Add the shadow row exactly the way the miss-graph pass does. */ - char db_path[700]; - snprintf(db_path, sizeof(db_path), "%s/delta1044.db", cache); - cbm_store_t *st = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(st); - ASSERT_EQ(cbm_store_upsert_project(st, "delta1044::missed", ""), CBM_STORE_OK); - cbm_store_close(st); - + const char *project = "guard-adr-missing"; cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - /* ── A + B: primary advertised, shadow hidden ─────────────────── */ - char *list = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"); - ASSERT_NOT_NULL(list); - ASSERT_NOT_NULL(strstr(list, "delta1044")); /* RED before: db skipped as ghost */ - ASSERT_NULL(strstr(list, "::missed")); /* shadow never advertised */ - free(list); - - /* ── C: the project still resolves and returns its node ───────── */ - char *q = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"search_graph\",\"arguments\":{" - "\"project\":\"delta1044\",\"name_pattern\":\"deltaFunc1044\",\"limit\":5}}}"); - ASSERT_NOT_NULL(q); - ASSERT_NOT_NULL(strstr(q, "deltaFunc1044")); - ASSERT_NULL(strstr(q, "not found")); - free(q); + char *resp = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"guard-adr-missing\",\"mode\":\"get\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed")); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.end_count, 0); + free(resp); cbm_mcp_server_free(srv); - - /* ── cleanup ───────────────────────────────────────────────────── */ - if (saved_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); - free(saved_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); - } - cbm_unlink(db_path); - char side1044[740]; - snprintf(side1044, sizeof(side1044), "%s-wal", db_path); - cbm_unlink(side1044); - snprintf(side1044, sizeof(side1044), "%s-shm", db_path); - cbm_unlink(side1044); + cleanup_project_db(cache, project); cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * QUERY STORE COHERENCE + READ-ONLY (data-integrity reproductions) - * - * Bug: query tools resolve the project store via resolve_store() -> - * cbm_store_open_path_query(), which opens the DB SQLITE_OPEN_READWRITE - * and runs configure_pragmas() with the WRITE pragmas - * (journal_mode=WAL + wal_checkpoint + synchronous). Two consequences: - * (a) read-only query tools MUTATE the on-disk DB (write pragmas), and - * (b) query tools FAIL outright on a read-only DB file / filesystem - * (the READWRITE open returns CANTOPEN -> resolve_store NULL -> - * "project not found"). - * Both read-only tests below are written reproduce-first and are RED on the - * unfixed code, GREEN once query opens are READONLY with read-only - * pragmas. - * ══════════════════════════════════════════════════════════════════ */ - -/* Reproduce-first: one MCP session caches a query connection to generation A, - * then the fixture models an independent writer publishing generation B by - * atomically replacing the project DB at the same cache path. Because - * resolve_store() keys its cache only by project name, the next query can reuse - * stale generation A. It must instead return generation B. */ -TEST(query_store_reopens_after_database_replacement) { - static const char project[] = "cbm-store-generation-refresh"; - static const char active_filename[] = "cbm-store-generation-refresh.db"; - static const char staged_filename[] = "cbm-store-generation-next.db"; +TEST(tool_manage_adr_legacy_migration_tries_without_blocking) { + const char *project = "guard-adr-legacy"; + char root[256]; + char cache[256]; + snprintf(root, sizeof(root), "%s/cbm-adr-legacy-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-adr-legacy-cache-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - char cache[512]; - snprintf(cache, sizeof(cache), "%s/cbm-store-generation-XXXXXX", cbm_tmpdir()); - bool cache_ready = cbm_mkdtemp(cache) != NULL; - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - if (cache_ready) { - cbm_setenv("CBM_CACHE_DIR", cache, 1); - } + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0); - bool generation_a_ready = - cache_ready && issue704_make_db(cache, active_filename, project, "GenerationA"); - cbm_mcp_server_t *srv = generation_a_ready ? cbm_mcp_server_new(NULL) : NULL; - bool server_ready = srv != NULL; + char adr_dir[CBM_SZ_1K]; + char adr_path[CBM_SZ_1K]; + snprintf(adr_dir, sizeof(adr_dir), "%s/.codebase-memory", root); + snprintf(adr_path, sizeof(adr_path), "%s/adr.md", adr_dir); + ASSERT_EQ(cbm_mkdir(adr_dir), 0); + FILE *fp = cbm_fopen(adr_path, "w"); + ASSERT_NOT_NULL(fp); + ASSERT_TRUE(fputs("## PURPOSE\nLegacy ADR.\n", fp) >= 0); + ASSERT_EQ(fclose(fp), 0); - char args[512]; - snprintf(args, sizeof(args), - "{\"project\":\"%s\",\"name_pattern\":\".*Generation.*\",\"limit\":10}", project); - char *before = srv ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL; - bool saw_generation_a = before && strstr(before, "GenerationA") != NULL; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *writer = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(writer); + ASSERT_EQ(cbm_store_upsert_project(writer, project, root), CBM_STORE_OK); + cbm_store_close(writer); - bool generation_b_ready = - cache_ready && issue704_make_db(cache, staged_filename, project, "GenerationB"); - char active_path[700]; - char staged_path[700]; - snprintf(active_path, sizeof(active_path), "%s/%s", cache, active_filename); - snprintf(staged_path, sizeof(staged_path), "%s/%s", cache, staged_filename); - bool replaced = generation_b_ready && cbm_rename_replace(staged_path, active_path) == 0; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_try_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_try_guard(srv, mcp_mutation_guard_probe_try_begin); - char *after = (srv && replaced) ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL; - bool saw_generation_b = after && strstr(after, "GenerationB") != NULL; - bool retained_generation_a = after && strstr(after, "GenerationA") != NULL; + char *busy_response = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-legacy\",\"mode\":\"get\"}"); + char *migrated_response = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-legacy\",\"mode\":\"get\"}"); + char *persisted_response = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-legacy\",\"mode\":\"get\"}"); + bool busy_read_returned_legacy = busy_response && strstr(busy_response, "Legacy ADR.") && + !strstr(busy_response, "\"isError\":true"); + bool migrated_read_returned_legacy = migrated_response && + strstr(migrated_response, "Legacy ADR.") && + !strstr(migrated_response, "\"isError\":true"); + bool migration_persisted = persisted_response && strstr(persisted_response, "Legacy ADR.") && + !strstr(persisted_response, "\"isError\":true"); - free(before); - free(after); - if (srv) { - cbm_mcp_server_free(srv); - } - if (cache_ready) { - cleanup_project_db(cache, project); - cleanup_project_db(cache, "cbm-store-generation-next"); - cbm_rmdir(cache); - } - restore_cache_dir(saved_copy); - free(saved_copy); + free(busy_response); + free(migrated_response); + free(persisted_response); + cbm_mcp_server_free(srv); + cbm_unlink(adr_path); + cbm_rmdir(adr_dir); + cbm_rmdir(root); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); - ASSERT_TRUE(cache_ready); - ASSERT_TRUE(generation_a_ready); - ASSERT_TRUE(server_ready); - ASSERT_TRUE(saw_generation_a); - ASSERT_TRUE(generation_b_ready); - ASSERT_TRUE(replaced); - ASSERT_TRUE(saw_generation_b); - ASSERT_FALSE(retained_generation_a); + ASSERT_TRUE(busy_read_returned_legacy); + ASSERT_TRUE(migrated_read_returned_legacy); + ASSERT_TRUE(migration_persisted); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.try_begin_count, 2); + ASSERT_EQ(probe.end_count, 1); + ASSERT_STR_EQ(probe.try_begin_projects[0], project); + ASSERT_STR_EQ(probe.try_begin_projects[1], project); + ASSERT_STR_EQ(probe.end_projects[0], project); PASS(); } -#define ROQ_PROJECT "cbm-roq-test" - -/* Whole-file byte snapshot. Returns malloc'd buffer (caller frees) and - * writes the length to *out_len. Returns NULL on failure. */ -static unsigned char *roq_read_file_bytes(const char *path, long *out_len) { - *out_len = 0; - FILE *fp = fopen(path, "rb"); - if (!fp) { - return NULL; - } - if (fseek(fp, 0, SEEK_END) != 0) { - fclose(fp); - return NULL; - } - long sz = ftell(fp); - if (sz < 0) { - fclose(fp); - return NULL; - } - rewind(fp); - unsigned char *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1); - if (!buf) { - fclose(fp); - return NULL; - } - size_t got = fread(buf, 1, (size_t)sz, fp); - fclose(fp); - if (got != (size_t)sz) { - free(buf); - return NULL; - } - *out_len = sz; - return buf; -} +/* A raw cbm_mcp_handle_tool() call is still one request lifetime. Cancellation + * published from inside a non-pipeline handler must therefore be accepted, + * observed before the write, and retired at completion so the next raw request + * on the same server is not poisoned. */ +TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean) { + const char *project = "raw-cancel-adr"; + char root[256]; + snprintf(root, sizeof(root), "%s/cbm-mcp-raw-adr-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, root), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); -static int roq_file_exists(const char *path) { - struct stat st; - return (stat(path, &st) == 0) ? 1 : 0; -} + mcp_mutation_guard_probe_t probe = { + .cancel_on_begin_call = 1, + .cancel_server = srv, + }; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); -/* ── (a) NO-MUTATION ────────────────────────────────────────────────── - * - * readonly_query_does_not_mutate_db - * - * Create a real project DB, convert it to rollback (DELETE) journal mode - * on disk, snapshot its exact bytes, run search_graph through the server, - * then re-snapshot. The buggy query path runs `PRAGMA journal_mode=WAL`, - * which rewrites the file header (1,1 -> 2,2) and spawns a -wal sidecar — - * so the snapshots differ. The fixed READONLY path runs no write pragma, - * so the file is byte-identical. - * - * The DELETE-mode fixture is what makes the mutation OBSERVABLE: on an - * already-WAL file `journal_mode=WAL` is a silent no-op, so we deliberately - * stage the DB in rollback mode (the same technique repro_issue557 uses to - * plant a deterministic trigger). - * - * WHY RED on unfixed code: - * journal_mode=WAL rewrites the header -> memcmp(before, after) != 0 and - * a -wal file is created while the cached store is open. Both assertions - * that demand "unchanged" fire. - * ─────────────────────────────────────────────────────────────────── */ -TEST(readonly_query_does_not_mutate_db) { - char tmp_cache[512]; - snprintf(tmp_cache, sizeof(tmp_cache), "%s/cbm_roq_a_XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(tmp_cache)) { - ASSERT_NOT_NULL(NULL); /* setup failure */ + char *cancelled_response = + cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"raw-cancel-adr\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nMUST NOT COMMIT.\\n\"}"); + bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && + strstr(cancelled_response, "\"isError\":true"); + + cbm_adr_t cancelled_adr = {0}; + int cancelled_lookup = cbm_store_adr_get(store, project, &cancelled_adr); + if (cancelled_lookup == CBM_STORE_OK) { + cbm_store_adr_free(&cancelled_adr); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", tmp_cache, 1); - char db_path[700]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, ROQ_PROJECT); - char wal_path[730]; - char shm_path[730]; - snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); - snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); + char *next_response = + cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"raw-cancel-adr\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nClean next request.\\n\"}"); + bool next_response_clean = next_response && strstr(next_response, "updated") && + !strstr(next_response, "cancelled") && + !strstr(next_response, "\"isError\":true"); + cbm_adr_t next_adr = {0}; + int next_lookup = cbm_store_adr_get(store, project, &next_adr); + bool next_write_committed = next_lookup == CBM_STORE_OK && next_adr.content && + strstr(next_adr.content, "Clean next request") && + !strstr(next_adr.content, "MUST NOT COMMIT"); + if (next_lookup == CBM_STORE_OK) { + cbm_store_adr_free(&next_adr); + } - /* Build the DB and flip it to rollback journal mode on disk. */ - cbm_store_t *setup = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(setup); - ASSERT_EQ(cbm_store_upsert_project(setup, ROQ_PROJECT, "/tmp/roq"), CBM_STORE_OK); - cbm_node_t node = {.project = ROQ_PROJECT, - .label = "Function", - .name = "ReadOnlyProbe", - .qualified_name = "roq.mod.ReadOnlyProbe", - .file_path = "mod.c"}; - ASSERT_TRUE(cbm_store_upsert_node(setup, &node) > 0); - ASSERT_EQ(cbm_store_exec(setup, "PRAGMA journal_mode=DELETE;"), 0); - cbm_store_close(setup); + free(cancelled_response); + free(next_response); + cbm_mcp_server_free(srv); + (void)cbm_rmdir(root); - /* Snapshot BEFORE any query. */ - long before_len = 0; - unsigned char *before = roq_read_file_bytes(db_path, &before_len); - ASSERT_NOT_NULL(before); + ASSERT_TRUE(probe.cancel_attempted); + ASSERT_TRUE(probe.cancel_accepted); + ASSERT_TRUE(cancellation_reported); + ASSERT_EQ(cancelled_lookup, CBM_STORE_NOT_FOUND); + ASSERT_TRUE(next_response_clean); + ASSERT_TRUE(next_write_committed); + ASSERT_EQ(probe.begin_count, 2); + ASSERT_EQ(probe.end_count, 2); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + ASSERT_STR_EQ(probe.begin_projects[1], project); + ASSERT_STR_EQ(probe.end_projects[1], project); + PASS(); +} - /* Run a query tool through the server (the resolve_store path). */ +/* The daemon publishes its transport request before entering MCP dispatch. A + * disconnect in that narrow interval must remain latched through the nested + * raw tool scope instead of being erased at dispatch entry. */ +TEST(tool_outer_request_scope_preserves_predispatch_cancel) { + const char *project = "outer-scope-cancel-adr"; + char root[256]; + (void)snprintf(root, sizeof(root), "%s/cbm-mcp-outer-cancel-XXXXXX", cbm_tmpdir()); + bool root_created = cbm_mkdtemp(root) != NULL; cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - char args[512]; - snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*ReadOnlyProbe.*\"}", - ROQ_PROJECT); - char *resp = cbm_mcp_handle_tool(srv, "search_graph", args); + cbm_store_t *store = cbm_mcp_server_store(srv); + bool project_ready = + root_created && store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; + cbm_mcp_server_set_project(srv, project); + bool outer_scope = project_ready && cbm_mcp_server_request_scope_begin(srv); + bool cancel_accepted = outer_scope && cbm_mcp_server_cancel_active(srv); + char *cancelled_response = + cancel_accepted + ? cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"outer-scope-cancel-adr\"," + "\"mode\":\"update\",\"content\":\"MUST NOT COMMIT\"}") + : NULL; + bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && + strstr(cancelled_response, "\"isError\":true"); + cbm_mcp_server_request_scope_end(srv); - /* Capture sidecar state WHILE the cached store is still open (the buggy - * RW+WAL open creates -wal here; on close it would be removed again). */ - int wal_while_open = roq_file_exists(wal_path); - int query_ok = (resp && strstr(resp, "ReadOnlyProbe") != NULL); - int query_failed = (resp && (strstr(resp, "not found") || strstr(resp, "not indexed"))); + char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL; + bool next_response_clean = next_response && strstr(next_response, "accepted") && + !strstr(next_response, "cancelled") && + !strstr(next_response, "\"isError\":true"); - cbm_mcp_server_free(srv); /* closes the store; header change is persisted */ + free(cancelled_response); + free(next_response); + cbm_mcp_server_free(srv); + (void)cbm_rmdir(root); - long after_len = 0; - unsigned char *after = roq_read_file_bytes(db_path, &after_len); + ASSERT_TRUE(root_created); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(project_ready); + ASSERT_TRUE(outer_scope); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(cancellation_reported); + ASSERT_TRUE(next_response_clean); + PASS(); +} - int identical = (before && after && before_len == after_len && - memcmp(before, after, (size_t)before_len) == 0); +/* Publish cancellation from the local index mutation guard: the request scope + * must already be active, and the cancellation must either stop before + * pipeline admission or remain set through pipeline binding. No project DB may + * be published, and the following request must start with a clean token. */ +TEST(tool_index_repository_early_raw_cancel_survives_index_entry) { + char cache[256]; + char repo[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-raw-index-cache-XXXXXX", cbm_tmpdir()); + snprintf(repo, sizeof(repo), "%s/cbm-mcp-raw-index-repo-XXXXXX", cbm_tmpdir()); + bool cache_created = cbm_mkdtemp(cache) != NULL; + bool repo_created = cbm_mkdtemp(repo) != NULL; - if (resp) { - free(resp); - } - free(before); - free(after); - cbm_unlink(db_path); - cbm_unlink(wal_path); - cbm_unlink(shm_path); - cbm_rmdir(tmp_cache); - if (saved_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); - free(saved_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + if (cache_created) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); } - ASSERT_TRUE(query_ok); /* read path ran and returned the node */ - ASSERT_FALSE(query_failed); /* not the "project not found" path */ - ASSERT_TRUE(identical); /* RED on buggy code: WAL pragma rewrote header */ - ASSERT_FALSE(wal_while_open); /* RED on buggy code: RW+WAL open spawned -wal */ - PASS(); -} - -/* ── (b) READ-ONLY FILESYSTEM ───────────────────────────────────────── - * - * readonly_query_succeeds_on_readonly_fs - * - * Create a real project DB (left in WAL journal mode, as the indexer - * writes it), then chmod the CONTAINING DIRECTORY to 0555 (read-only) to - * simulate a read-only mount / immutable media, then run search_graph. - * - * Note on why the directory (not just the file) must be read-only: SQLite's - * unix VFS auto-downgrades a failed O_RDWR main-db open to O_RDONLY, so a - * 0444 *file* alone does NOT surface the bug — the connection silently - * becomes read-only and, with a writable dir, still creates the WAL -shm - * and reads. The genuine read-only-FS symptom is the WAL write-pragma - * (journal_mode=WAL) being unable to create the -shm/-wal sidecars in a - * read-only directory. - * - * WHY RED on unfixed code: - * cbm_store_open_path_query() runs configure_pragmas(.., false) which - * executes `PRAGMA journal_mode = WAL`. In a read-only directory the WAL - * wal-index (-shm) cannot be created, so the pragma errors -> - * configure_pragmas fails -> the open returns NULL -> resolve_store() - * returns NULL -> the handler emits "project not found or not indexed". - * - * GREEN on fixed code: - * the READONLY open skips the WAL write-pragma; the plain READONLY open - * of a WAL-mode DB in a read-only dir still needs -shm, so it fails and - * the immutable-URI fallback (file:..?immutable=1) reads the main DB - * file directly and the query returns the node. (This is the test that - * exercises the immutable fallback path.) - * ─────────────────────────────────────────────────────────────────── */ -TEST(readonly_query_succeeds_on_readonly_fs) { - char tmp_cache[512]; - snprintf(tmp_cache, sizeof(tmp_cache), "%s/cbm_roq_b_XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(tmp_cache)) { - ASSERT_NOT_NULL(NULL); /* setup failure */ + char *project = repo_created ? cbm_project_name_from_path(repo) : NULL; + cbm_mcp_server_t *srv = + cache_created && repo_created && project ? cbm_mcp_server_new(NULL) : NULL; + mcp_mutation_guard_probe_t probe = { + .cancel_on_begin_call = 1, + .cancel_server = srv, + }; + if (srv) { + cbm_mcp_server_set_background_tasks(srv, false); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", tmp_cache, 1); - char db_path[700]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, ROQ_PROJECT); - char wal_path[730]; - char shm_path[730]; - snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); - snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); + char args[CBM_SZ_1K]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo); + char *cancelled_response = srv ? cbm_mcp_handle_tool(srv, "index_repository", args) : NULL; + bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && + strstr(cancelled_response, "\"isError\":true"); - /* Build the DB in its natural WAL journal mode and ensure it is cleanly - * checkpointed (no -wal frames) so the immutable fallback can read all - * data from the main file. */ - cbm_store_t *setup = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(setup); - ASSERT_EQ(cbm_store_upsert_project(setup, ROQ_PROJECT, "/tmp/roq"), CBM_STORE_OK); - cbm_node_t node = {.project = ROQ_PROJECT, - .label = "Function", - .name = "ReadOnlyProbe", - .qualified_name = "roq.mod.ReadOnlyProbe", - .file_path = "mod.c"}; - ASSERT_TRUE(cbm_store_upsert_node(setup, &node) > 0); - (void)cbm_store_checkpoint(setup); /* fold WAL frames into the main file */ - cbm_store_close(setup); /* clean close removes -wal/-shm */ + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing-project"); + bool no_project_published = !cbm_file_exists(db_path); - /* Make the containing directory read-only (simulate a read-only mount). - * SQLite can still traverse + read files, but cannot create -shm/-wal. */ - ASSERT_EQ(chmod(tmp_cache, 0555), 0); + char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL; + bool next_response_clean = next_response && strstr(next_response, "accepted") && + !strstr(next_response, "cancelled") && + !strstr(next_response, "\"isError\":true"); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + free(cancelled_response); + free(next_response); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + if (cache_created) { + (void)cbm_rmdir(cache); + } + if (repo_created) { + (void)cbm_rmdir(repo); + } + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + free(project); + + ASSERT_TRUE(cache_created); + ASSERT_TRUE(repo_created); ASSERT_NOT_NULL(srv); - char args[512]; - snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*ReadOnlyProbe.*\"}", - ROQ_PROJECT); - char *resp = cbm_mcp_handle_tool(srv, "search_graph", args); + ASSERT_TRUE(probe.cancel_attempted); + ASSERT_TRUE(probe.cancel_accepted); + ASSERT_TRUE(cancellation_reported); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 1); + ASSERT_TRUE(no_project_published); + ASSERT_TRUE(next_response_clean); + PASS(); +} - int query_ok = (resp && strstr(resp, "ReadOnlyProbe") != NULL); - int query_failed = (resp && (strstr(resp, "not found") || strstr(resp, "not indexed"))); +typedef struct { + cbm_mcp_server_t *server; + const char *args; + atomic_int done; + char *response; +} mcp_index_lock_wait_request_t; + +static void *mcp_index_lock_wait_request(void *arg) { + mcp_index_lock_wait_request_t *request = arg; + request->response = cbm_mcp_handle_tool(request->server, "index_repository", request->args); + atomic_store(&request->done, 1); + return NULL; +} - if (resp) { - free(resp); +/* Request cancellation must remain effective after index_repository passes its + * early cancellation check but before it installs active_pipeline. Holding the + * branch-side global lock makes that handoff deterministic: cancellation must + * finish the upstream request scope while the owner still holds the lock, and + * must not consume or release the owner's lock. */ +TEST(tool_index_repository_lock_wait_honors_request_cancel) { + char cache[CBM_SZ_256]; + char repo[CBM_SZ_256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-lock-cancel-cache-XXXXXX", cbm_tmpdir()); + snprintf(repo, sizeof(repo), "%s/cbm-mcp-lock-cancel-repo-XXXXXX", cbm_tmpdir()); + bool cache_created = cbm_mkdtemp(cache) != NULL; + bool repo_created = cbm_mkdtemp(repo) != NULL; + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + if (cache_created) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); } - cbm_mcp_server_free(srv); - /* Restore write permission on the dir BEFORE unlink (cannot remove dir - * entries while the directory is read-only). */ - chmod(tmp_cache, 0755); - cbm_unlink(db_path); - cbm_unlink(wal_path); - cbm_unlink(shm_path); - cbm_rmdir(tmp_cache); - if (saved_copy) { - cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); - free(saved_copy); - } else { - cbm_unsetenv("CBM_CACHE_DIR"); + char *project = repo_created ? cbm_project_name_from_path(repo) : NULL; + cbm_mcp_server_t *srv = + cache_created && repo_created && project ? cbm_mcp_server_new(NULL) : NULL; + if (srv) { + cbm_mcp_server_set_background_tasks(srv, false); } - ASSERT_FALSE(query_failed); /* RED on buggy code: WAL pragma fails on RO dir */ - ASSERT_TRUE(query_ok); /* RED on buggy code: no node returned */ - PASS(); -} + char args[CBM_SZ_1K]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo); + mcp_index_lock_wait_request_t request = { + .server = srv, + .args = args, + .response = NULL, + }; + atomic_init(&request.done, 0); + + cbm_thread_t request_thread; + cbm_pipeline_lock(); + bool request_started = + srv && cbm_thread_create(&request_thread, 0, mcp_index_lock_wait_request, &request) == 0; + uint64_t wait_deadline = cbm_now_ms() + MCP_REQUEST_TEST_TIMEOUT_SECONDS * CBM_MSEC_PER_SEC; + while (request_started && cbm_pipeline_lock_waiter_count_for_testing() == 0 && + cbm_now_ms() < wait_deadline) { + cbm_usleep(CBM_USEC_PER_SEC / CBM_MSEC_PER_SEC); + } + bool reached_lock_wait = request_started && cbm_pipeline_lock_waiter_count_for_testing() == 1; + bool cancel_accepted = reached_lock_wait && cbm_mcp_server_cancel_active(srv); + uint64_t cancel_deadline = cbm_now_ms() + CBM_MSEC_PER_SEC; + while (cancel_accepted && atomic_load(&request.done) == 0 && cbm_now_ms() < cancel_deadline) { + cbm_usleep(CBM_USEC_PER_SEC / CBM_MSEC_PER_SEC); + } + bool finished_while_owner_held_lock = atomic_load(&request.done) != 0; + bool owner_still_holds_lock = !cbm_pipeline_try_lock(); + cbm_pipeline_unlock(); + if (request_started) { + (void)cbm_thread_join(&request_thread); + } + + bool cancellation_reported = request.response && strstr(request.response, "cancelled") && + strstr(request.response, "\"isError\":true"); + bool waiter_released = cbm_pipeline_lock_waiter_count_for_testing() == 0; + bool lock_reusable = cbm_pipeline_try_lock(); + if (lock_reusable) { + cbm_pipeline_unlock(); + } -#undef ROQ_PROJECT + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing-project"); + bool no_project_published = !cbm_file_exists(db_path); -/* ══════════════════════════════════════════════════════════════════ - * #823 — CLI/supervised index_repository must preserve name override - * ══════════════════════════════════════════════════════════════════ */ + free(request.response); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + if (cache_created) { + (void)cbm_rmdir(cache); + } + if (repo_created) { + (void)cbm_rmdir(repo); + } + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + free(project); -enum { - IDX823_OK = 0, - IDX823_NO_SERVER = 61, - IDX823_NO_RESULT = 62, - IDX823_NOT_INDEXED = 63, - IDX823_RESPONSE_NAME_MISSING = 64, - IDX823_LIST_NAME_MISSING = 65, - IDX823_SEARCH_FAILED = 66, - IDX823_PARENT_GUARD_USED = 67, -}; + ASSERT_TRUE(cache_created); + ASSERT_TRUE(repo_created); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(request_started); + ASSERT_TRUE(reached_lock_wait); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(finished_while_owner_held_lock); + ASSERT_TRUE(owner_still_holds_lock); + ASSERT_TRUE(cancellation_reported); + ASSERT_TRUE(waiter_released); + ASSERT_TRUE(lock_reusable); + ASSERT_TRUE(no_project_published); + PASS(); +} -#ifndef _WIN32 /* helper used only by the POSIX fork harness below */ -static int idx823_supervised_name_override_check(const char *repo_dir, const char *custom_name) { - /* Match the real CLI/MCP server state: a marked host with the supervisor - * enabled. The worker receives the same args JSON the CLI forwards. */ - cbm_index_supervisor_mark_host(); - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); - cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); +TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds) { + char repo[256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-guard-XXXXXX"); + if (!cbm_mkdtemp(repo)) { + PASS(); + } cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - return IDX823_NO_SERVER; - } - /* A supervised local index transfers project-lock ownership to the worker. - * Denying the parent guard is therefore harmless and proves the parent did - * not acquire a lease before spawning. RED on the former ordering, which - * returned "blocked" without ever starting the worker. */ - mcp_mutation_guard_probe_t parent_guard = {.deny_begin_call = 1}; + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); + + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 3}; cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &parent_guard); + mcp_mutation_guard_probe_end, &probe); - char args[1024]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\",\"name\":\"%s\"}", - repo_dir, custom_name); + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"zzz-target\",\"000-target\",\"zzz-target\"]}", + repo); char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - int code = IDX823_OK; - if (parent_guard.begin_count != 0 || parent_guard.end_count != 0) { - code = IDX823_PARENT_GUARD_USED; - } else if (!resp) { - code = IDX823_NO_RESULT; - } else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { - code = IDX823_NOT_INDEXED; - } else { - char expected[256]; - snprintf(expected, sizeof(expected), "\"project\":\"%s\"", custom_name); - if (!response_contains_json_fragment(resp, expected)) { - code = IDX823_RESPONSE_NAME_MISSING; - } + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "blocked")); + + /* The source plus two unique targets are acquired in lexical order. The + * third acquisition is denied, so only the first two are unwound. */ + ASSERT_EQ(probe.begin_count, 3); + ASSERT_TRUE(strcmp(probe.begin_projects[0], probe.begin_projects[1]) < 0); + ASSERT_TRUE(strcmp(probe.begin_projects[1], probe.begin_projects[2]) < 0); + int low_target_count = 0; + int high_target_count = 0; + for (int i = 0; i < probe.begin_count; i++) { + low_target_count += strcmp(probe.begin_projects[i], "000-target") == 0; + high_target_count += strcmp(probe.begin_projects[i], "zzz-target") == 0; } + ASSERT_EQ(low_target_count, 1); + ASSERT_EQ(high_target_count, 1); + ASSERT_EQ(probe.end_count, 2); + ASSERT_STR_EQ(probe.end_projects[0], probe.begin_projects[1]); + ASSERT_STR_EQ(probe.end_projects[1], probe.begin_projects[0]); free(resp); - if (code == IDX823_OK) { - char *projects = cbm_mcp_handle_tool(srv, "list_projects", "{}"); - char expected[256]; - snprintf(expected, sizeof(expected), "\"name\":\"%s\"", custom_name); - if (!projects || !response_contains_json_fragment(projects, expected)) { - code = IDX823_LIST_NAME_MISSING; - } - free(projects); + cbm_mcp_server_free(srv); + cbm_rmdir(repo); + PASS(); +} + +/* Project-lock keys ASCII-fold A-Z, so case aliases must be one lease here too. + * Otherwise Foo + foo self-deadlocks, and two requests whose raw strcmp order + * differs can acquire the same OS locks in opposite (ABBA) order. Keep the + * original spellings: folding is only the comparison key, not a lookup value. */ +TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) { + char repo[256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-case-guard-XXXXXX"); + if (!cbm_mkdtemp(repo)) { + PASS(); } - if (code == IDX823_OK) { - char q[512]; - snprintf(q, sizeof(q), - "{\"project\":\"%s\",\"name_pattern\":\"idx823_fn\",\"label\":\"Function\"}", - custom_name); - char *sr = cbm_mcp_handle_tool(srv, "search_graph", q); - if (!sr || !strstr(sr, "idx823_fn")) { - code = IDX823_SEARCH_FAILED; - } - free(sr); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); + + mcp_mutation_guard_probe_t first = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &first); + char first_args[CBM_SZ_2K]; + snprintf(first_args, sizeof(first_args), + "{\"repo_path\":\"%s\",\"name\":\"Zulu\"," + "\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"Foo\",\"foo\",\"Alpha\"]}", + repo); + char *first_resp = cbm_mcp_handle_tool(srv, "index_repository", first_args); + ASSERT_NOT_NULL(first_resp); + free(first_resp); + + mcp_mutation_guard_probe_t second = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &second); + char second_args[CBM_SZ_2K]; + snprintf(second_args, sizeof(second_args), + "{\"repo_path\":\"%s\",\"name\":\"zULU\"," + "\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"foo\",\"ALPHA\",\"FOO\"]}", + repo); + char *second_resp = cbm_mcp_handle_tool(srv, "index_repository", second_args); + ASSERT_NOT_NULL(second_resp); + free(second_resp); + + ASSERT_EQ(first.begin_count, 3); + ASSERT_EQ(first.end_count, 3); + ASSERT_EQ(second.begin_count, 3); + ASSERT_EQ(second.end_count, 3); + for (int i = 0; i < 3; i++) { + ASSERT_TRUE( + mcp_test_project_keys_equivalent(first.begin_projects[i], second.begin_projects[i])); + ASSERT_TRUE( + mcp_test_project_keys_equivalent(first.end_projects[i], first.begin_projects[2 - i])); + ASSERT_TRUE( + mcp_test_project_keys_equivalent(second.end_projects[i], second.begin_projects[2 - i])); } + ASSERT_STR_EQ(first.begin_projects[0], "Alpha"); + ASSERT_STR_EQ(first.begin_projects[1], "Foo"); + ASSERT_STR_EQ(first.begin_projects[2], "Zulu"); + ASSERT_STR_EQ(second.begin_projects[0], "ALPHA"); + ASSERT_STR_EQ(second.begin_projects[1], "FOO"); + ASSERT_STR_EQ(second.begin_projects[2], "zULU"); cbm_mcp_server_free(srv); - return code; + cbm_rmdir(repo); + PASS(); } -#endif -TEST(index_repository_cli_name_override_issue823) { -#ifdef _WIN32 - SKIP_PLATFORM("POSIX fork harness required to isolate supervisor host mark"); -#else - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx823-repo-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - FAIL("cbm_mkdtemp repo failed"); - } +/* A wildcard means "all projects" and therefore cannot be combined with a + * named target. Accepting the mixed form both obscures caller intent and lets + * the cross-repo pass create/use a literal "*.db" target on POSIX. Validation + * must happen before any project mutation lease is acquired. */ +TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-idx823-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - th_rmtree(tmp_dir); - FAIL("cbm_mkdtemp cache failed"); - } + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-wildcard-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); const char *saved_cache = getenv("CBM_CACHE_DIR"); char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - ASSERT_EQ(th_write_file(src_path, "def idx823_fn():\n return 823\n"), 0); + char *project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(project); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); - const char *custom_name = "issue823-custom-project"; - int code = -1; - bool signalled = false; - int sig = 0; + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - fflush(NULL); - pid_t pid = fork(); - if (pid == 0) { - alarm(60); - _exit(idx823_supervised_name_override_check(tmp_dir, custom_name)); - } - ASSERT_TRUE(pid > 0); - int status = 0; - (void)waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - code = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - signalled = true; - sig = WTERMSIG(status); - } + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"*\",\"named-target\"]}", + cache); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool rejected = resp && strstr(resp, "\"isError\":true") != NULL; + bool explained = resp && strstr(resp, "target_projects") && strstr(resp, "*") && + (strstr(resp, "only") || strstr(resp, "combin")); + int begin_count = probe.begin_count; + int end_count = probe.end_count; - char *path_project = cbm_project_name_from_path(tmp_dir); - cleanup_project_db(cache, custom_name); - cleanup_project_db(cache, path_project); - free(path_project); + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cleanup_project_db(cache, "*"); + cleanup_project_db(cache, "named-target"); + free(project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - th_rmtree(cache); - th_rmtree(tmp_dir); + cbm_rmdir(cache); - if (signalled) { - printf(" child killed by signal %d (alarm => worker hang)\n", sig); - } else if (code != IDX823_OK) { - printf(" child exit code %d (64=response name, 65=list name, " - "66=search, 67=parent guard used)\n", - code); - } - ASSERT_FALSE(signalled); - ASSERT_EQ(code, IDX823_OK); + ASSERT_TRUE(rejected); + ASSERT_TRUE(explained); + ASSERT_EQ(begin_count, 0); + ASSERT_EQ(end_count, 0); PASS(); -#endif } -/* ══════════════════════════════════════════════════════════════════ - * #845 — supervisor gate must not wrap embedders of cbm_mcp_handle_tool - * ══════════════════════════════════════════════════════════════════ */ +/* Cancellation can arrive while the final mutation lease is being acquired. + * The cross-repo operation must advertise itself through cancel_active(), + * observe the pending cancellation before doing cross-project writes, and + * unwind every lease it acquired. */ +TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-cancel-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); -TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery) { - char response[] = "{\"status\":\"indexed\"}"; - cbm_index_worker_result_t result = { - .outcome = CBM_PROC_CLEAN, - .exit_code = 0, - .tree_quiesced = true, - .response = response, + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(project); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + mcp_mutation_guard_probe_t probe = { + .cancel_on_begin_call = 3, + .cancel_server = srv, }; - ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), CBM_MCP_SUPERVISED_RESULT_SUCCESS); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - result.cancellation_requested = true; - ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), - CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); - result.cancellation_requested = false; - result.tree_quiesced = false; - ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), - CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); - result.tree_quiesced = true; - result.supervision_failed = true; - ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), - CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"000-cancel-target\",\"zzz-cancel-target\"]}", + cache); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool response_cancelled = resp && strstr(resp, "cancelled") != NULL; + bool cancel_attempted = probe.cancel_attempted; + bool cancel_accepted = probe.cancel_accepted; + int begin_count = probe.begin_count; + int end_count = probe.end_count; + bool reverse_unwind = begin_count == 3 && end_count == 3 && + strcmp(probe.end_projects[0], probe.begin_projects[2]) == 0 && + strcmp(probe.end_projects[1], probe.begin_projects[1]) == 0 && + strcmp(probe.end_projects[2], probe.begin_projects[0]) == 0; - result.supervision_failed = false; - result.outcome = CBM_PROC_CRASH; - result.response = NULL; - ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), - CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE); - ASSERT_EQ(cbm_mcp_supervised_result_disposition(-1, &result), - CBM_MCP_SUPERVISED_RESULT_FALLBACK); + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cleanup_project_db(cache, "000-cancel-target"); + cleanup_project_db(cache, "zzz-cancel-target"); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(cancel_attempted); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(response_cancelled); + ASSERT_EQ(begin_count, 3); + ASSERT_EQ(end_count, 3); + ASSERT_TRUE(reverse_unwind); PASS(); } -/* Child-side check: index a tiny fixture and verify it ran IN-PROCESS. - * Distinct exit codes so the parent can report the exact failure mode. */ -enum { - IDX845_OK = 0, - IDX845_SPAWNED = 41, /* a worker subprocess was spawned — the #845 bug */ - IDX845_NO_RESULT = 42, /* handle_tool returned NULL */ - IDX845_NOT_INDEXED = 43, /* response lacks status=indexed */ -}; +/* cbm_store_open_path() creates its path. Cross-repo validation must therefore + * reject an absent source or named target before the matcher opens either one; + * otherwise a typo silently becomes a valid-looking empty project database. */ +TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-missing-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); -static int idx845_index_inprocess_check(const char *repo_dir) { - int spawns_before = cbm_index_supervisor_spawn_count(); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *existing_target = "existing-cross-target"; + const char *missing_target = "missing-cross-target"; + ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, existing_target, cache)); + + char source_db_path[CBM_SZ_1K]; + char missing_target_db_path[CBM_SZ_1K]; + snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project); + snprintf(missing_target_db_path, sizeof(missing_target_db_path), "%s/%s.db", cache, + missing_target); + ASSERT_FALSE(cbm_file_exists(source_db_path)); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - return IDX845_NO_RESULT; - } - char args[1024]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\"]}", + cache, existing_target); + char *source_resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool source_failed = source_resp && strstr(source_resp, "\"isError\":true"); + bool source_reported = + source_resp && (strstr(source_resp, "not indexed") || strstr(source_resp, "not found") || + strstr(source_resp, "missing")); + bool source_ghost_created = cbm_file_exists(source_db_path); + free(source_resp); + + cleanup_project_db(cache, source_project); + ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, source_project, cache)); + ASSERT_FALSE(cbm_file_exists(missing_target_db_path)); + + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\"]}", + cache, missing_target); + char *target_resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool target_failed = target_resp && strstr(target_resp, "\"isError\":true"); + bool target_reported = + target_resp && (strstr(target_resp, "not indexed") || strstr(target_resp, "not found") || + strstr(target_resp, "missing")); + bool target_ghost_created = cbm_file_exists(missing_target_db_path); + free(target_resp); - int code = IDX845_OK; - if (cbm_index_supervisor_spawn_count() != spawns_before) { - code = IDX845_SPAWNED; - } else if (!resp) { - code = IDX845_NO_RESULT; - } else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { - code = IDX845_NOT_INDEXED; - } - free(resp); cbm_mcp_server_free(srv); - return code; + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, existing_target); + cleanup_project_db(cache, missing_target); + free(source_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(source_failed); + ASSERT_TRUE(source_reported); + ASSERT_FALSE(source_ghost_created); + ASSERT_TRUE(target_failed); + ASSERT_TRUE(target_reported); + ASSERT_FALSE(target_ghost_created); + PASS(); } -TEST(index_supervisor_gate_requires_marked_host_issue845) { - /* #845: index_repository via cbm_mcp_handle_tool from an EMBEDDER (this test - * binary) must index IN-PROCESS even with CBM_INDEX_SUPERVISOR unset. The - * supervisor gate may only wrap a process that called - * cbm_index_supervisor_mark_host() — i.e. the real binary's main(). Before - * the fix, should_wrap() was true for ANY embedder: the gate resolved the - * CURRENT binary (this test runner!) and spawned - * ' cli --index-worker --index-worker-build …', which a test binary - * interprets as suite-filter args → it re-runs test suites in the child → - * recursive spawn chains (observed 11-min hangs; kernel VM-map load during - * the 2026-07-04 host panics). - * - * POSIX: run the call in a forked child under alarm(20) so the pre-fix - * recursive behaviour cannot hang the runner; the child reports via exit - * code. Windows: no fork — run in-process (safe once the gate is fixed; the - * pre-fix redness is demonstrated on POSIX). */ - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx845-repo-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } +/* Named targets are a set, not a work list. A duplicate must be leased, + * scanned, and counted once; the fixture provides one real edge so the result + * counters cannot pass vacuously at zero. */ +TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-idx845-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); - } + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-dedupe-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); const char *saved_cache = getenv("CBM_CACHE_DIR"); char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - /* The point of the guard: NO kill switch. The gate itself must keep an - * unmarked host in-process. Save + restore the ambient value. */ - const char *saved_sv = getenv("CBM_INDEX_SUPERVISOR"); - char *saved_sv_copy = saved_sv ? strdup(saved_sv) : NULL; - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *target_project = "cross-dedupe-target"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return 'ok'\n", fp); - fclose(fp); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); - int code = -1; - bool signalled = false; - int sig = 0; -#ifdef _WIN32 - code = idx845_index_inprocess_check(tmp_dir); -#else - fflush(NULL); - pid_t pid = fork(); - if (pid == 0) { - alarm(20); /* pre-fix spawn chain must die here, not hang the runner */ - _exit(idx845_index_inprocess_check(tmp_dir)); - } - ASSERT_TRUE(pid > 0); - int status = 0; - (void)waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - code = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - signalled = true; - sig = WTERMSIG(status); - } -#endif + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\",\"%s\"]}", + cache, target_project, target_project); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool succeeded = resp && strstr(resp, "\"isError\":true") == NULL; + bool scanned_once = response_contains_json_fragment(resp, "\"projects_scanned\":1"); + bool counted_once = response_contains_json_fragment(resp, "\"cross_http_calls\":1") && + response_contains_json_fragment(resp, "\"total_cross_edges\":1"); - /* Restore env BEFORE asserting so a red run doesn't leak state. */ - if (saved_sv_copy) { - cbm_setenv("CBM_INDEX_SUPERVISOR", saved_sv_copy, 1); - free(saved_sv_copy); - } else { - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - } - char *project = cbm_project_name_from_path(tmp_dir); - cleanup_project_db(cache, project); - free(project); + char source_db_path[CBM_SZ_1K]; + char target_db_path[CBM_SZ_1K]; + snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project); + snprintf(target_db_path, sizeof(target_db_path), "%s/%s.db", cache, target_project); + cbm_store_t *source = cbm_store_open_path_query(source_db_path); + cbm_store_t *target = cbm_store_open_path_query(target_db_path); + int source_cross_edges = + source ? cbm_store_count_edges_by_type(source, source_project, "CROSS_HTTP_CALLS") : -1; + int target_cross_edges = + target ? cbm_store_count_edges_by_type(target, target_project, "CROSS_HTTP_CALLS") : -1; + cbm_store_close(source); + cbm_store_close(target); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + free(source_project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - remove(src_path); cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - if (signalled) { - printf(" child killed by signal %d (alarm => recursive spawn chain hang)\n", sig); - } else if (code != IDX845_OK) { - printf(" child exit code %d (41=worker spawned, 42=no result, 43=not indexed)\n", code); - } - ASSERT_FALSE(signalled); - ASSERT_EQ(code, IDX845_OK); + ASSERT_TRUE(succeeded); + ASSERT_TRUE(scanned_once); + ASSERT_TRUE(counted_once); + ASSERT_EQ(source_cross_edges, 1); + ASSERT_EQ(target_cross_edges, 1); PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * Mandatory supervision must fail closed in real CBM hosts - * ══════════════════════════════════════════════════════════════════ */ +/* The OTHER half of the cross-repo missing-input contract, and the companion to + * tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases. + * + * pass_cross_repo.h:35-37 states both outcomes, and they are opposite: "a + * missing source sets source_missing and runs nothing; a missing named target + * is skipped and counted in targets_missing. Neither creates a database." + * A missing SOURCE must be reported as an error, because nothing was matched + * from. A missing TARGET must NOT fail the run — one unindexed project in a + * target list would otherwise sink an otherwise-good scan — it is skipped and + * surfaced as a count so the caller can see what was left out. + * + * Both halves are pinned so neither can regress alone. Restoring the + * source_missing error without this test would invite "simplifying" the two + * cases back together, which is exactly how the source half was lost: the field + * was set by pass_cross_repo.c and read by nobody, so an unindexed source + * returned a success envelope with every edge count at zero — indistinguishable + * from a repository that genuinely shares no interfaces. */ +TEST(tool_cross_repo_missing_target_is_skipped_and_counted_not_failed) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-skip-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); -/* A real CBM host must never turn a supervisor refusal into permission to run - * the native index pipeline in its own long-lived process. The legacy - * CBM_INDEX_SUPERVISOR=0 switch is a deterministic start-failure seam here: on - * the buggy path should_wrap() returned false, the parent mutation guard ran, - * and the project DB was written in-process. The fixed path keeps supervision - * mandatory, returns an error, and leaves both the guard and filesystem - * untouched. Host marking is process-lifetime state, so isolate it in a clean - * re-exec. posix_spawn stays reliable after earlier tests created threads, - * whereas a late raw fork can fail transiently under sanitizers on macOS. */ -enum { - IDXFAILCLOSED_OK = 0, - IDXFAILCLOSED_NO_SERVER = 81, - IDXFAILCLOSED_PARENT_MUTATED = 82, - IDXFAILCLOSED_NO_RESPONSE = 83, - IDXFAILCLOSED_INDEXED = 84, - IDXFAILCLOSED_NOT_ERROR = 85, -}; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); -#ifndef _WIN32 -int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, const char *cache_dir) { - (void)cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); - cbm_index_supervisor_mark_host(); - (void)cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *target_project = "cross-skip-target"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - return IDXFAILCLOSED_NO_SERVER; - } - mcp_mutation_guard_probe_t parent_guard = {0}; - cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, - mcp_mutation_guard_probe_end, &parent_guard); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); - char args[CBM_SZ_4K]; - (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir); - char *response = cbm_mcp_handle_tool(srv, "index_repository", args); + /* One real target and one that was never indexed. */ + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\",\"cross-skip-never-indexed\"]}", + cache, target_project); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - int result = IDXFAILCLOSED_OK; - if (parent_guard.begin_count != 0 || parent_guard.end_count != 0) { - result = IDXFAILCLOSED_PARENT_MUTATED; - } else if (!response) { - result = IDXFAILCLOSED_NO_RESPONSE; - } else if (response_contains_json_fragment(response, "\"status\":\"indexed\"")) { - result = IDXFAILCLOSED_INDEXED; - } else if (!response_contains_json_fragment(response, "\"status\":\"error\"") || - !response_contains_json_fragment(response, "\"outcome\":\"spawn_failed\"")) { - result = IDXFAILCLOSED_NOT_ERROR; - } + bool succeeded = resp && strstr(resp, "\"isError\":true") == NULL; + bool counted_missing = response_contains_json_fragment(resp, "\"targets_missing\":1"); + /* The indexed target still matched: skipping one must not abort the scan. */ + bool still_scanned = response_contains_json_fragment(resp, "\"projects_scanned\":1"); + /* No database is created for the never-indexed target. */ + char ghost_db[CBM_SZ_1K]; + snprintf(ghost_db, sizeof(ghost_db), "%s/cross-skip-never-indexed.db", cache); + bool no_ghost = !test_file_exists_mcp(ghost_db); - free(response); + free(resp); cbm_mcp_server_free(srv); - return result; -} + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + free(source_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); -static bool idxfailclosed_self_path(char out[CBM_SZ_4K]) { -#ifdef __APPLE__ - int length = proc_pidpath(getpid(), out, CBM_SZ_4K); - bool resolved = length > 0 && length < CBM_SZ_4K; - if (resolved) { - out[length] = '\0'; - } - return resolved; -#elif defined(__linux__) - ssize_t length = readlink("/proc/self/exe", out, CBM_SZ_4K - 1); - bool resolved = length > 0 && length < (ssize_t)CBM_SZ_4K - 1; - if (resolved) { - out[length] = '\0'; - } - return resolved; -#else - (void)out; - return false; -#endif + ASSERT_TRUE(succeeded); + ASSERT_TRUE(counted_missing); + ASSERT_TRUE(still_scanned); + ASSERT_TRUE(no_ghost); + PASS(); } -#endif -TEST(index_supervisor_start_failure_is_fail_closed_in_real_host) { -#ifdef _WIN32 - SKIP_PLATFORM("immutable host mark needs fork isolation (POSIX-only)"); -#else - char repo_dir[CBM_SZ_1K]; - char cache_dir[CBM_SZ_1K]; - (void)snprintf(repo_dir, sizeof(repo_dir), "%s/cbm-idx-failclosed-repo-XXXXXX", cbm_tmpdir()); - (void)snprintf(cache_dir, sizeof(cache_dir), "%s/cbm-idx-failclosed-cache-XXXXXX", - cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(repo_dir)); - ASSERT_NOT_NULL(cbm_mkdtemp(cache_dir)); +/* `name` is the documented index project-name override and must identify the + * cross-repo source too. Deriving from repo_path here makes custom-named + * projects impossible to rescan even though ordinary indexing created them. */ +TEST(tool_cross_repo_honors_source_name_override) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-name-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - char source_path[CBM_SZ_4K]; - (void)snprintf(source_path, sizeof(source_path), "%s/should_not_index.py", repo_dir); - FILE *source = cbm_fopen(source_path, "wb"); - ASSERT_NOT_NULL(source); - ASSERT_TRUE(fputs("def should_not_index():\n return True\n", source) >= 0); - ASSERT_EQ(fclose(source), 0); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); - char *project = cbm_project_name_from_path(repo_dir); - ASSERT_NOT_NULL(project); - char db_path[CBM_SZ_4K]; - (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache_dir, project); + const char *source_project = "cross-custom-source"; + const char *target_project = "cross-custom-target"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); - char self_path[CBM_SZ_4K] = {0}; - ASSERT_TRUE(idxfailclosed_self_path(self_path)); - char *const child_argv[] = { - self_path, "__cbm_mcp_idxfailclosed_probe", repo_dir, cache_dir, NULL, - }; - (void)fflush(NULL); - pid_t child = -1; - ASSERT_EQ(posix_spawn(&child, self_path, NULL, NULL, child_argv, environ), 0); - ASSERT_TRUE(child > 0); - int status = 0; - ASSERT_EQ(waitpid(child, &status, 0), child); - bool exited = WIFEXITED(status); - int child_result = exited ? WEXITSTATUS(status) : -1; - bool database_absent = !cbm_file_exists(db_path); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"name\":\"%s\"," + "\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\"]}", + cache, source_project, target_project); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool succeeded = resp && !response_contains_json_fragment(resp, "\"isError\":true") && + response_contains_json_fragment(resp, "\"cross_http_calls\":1"); - cleanup_project_db(cache_dir, project); - free(project); - (void)cbm_unlink(source_path); - (void)th_rmtree(repo_dir); - (void)th_rmtree(cache_dir); + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); - ASSERT_TRUE(exited); - ASSERT_EQ(child_result, IDXFAILCLOSED_OK); - ASSERT_TRUE(database_absent); + ASSERT_TRUE(succeeded); PASS(); -#endif } -/* ═══════════════════════════════════════════════════════════ - * #832 — background auto-index + watcher re-index must run in the - * supervised worker SUBPROCESS (RSS isolation) - * ══════════════════════════════════════════════════════════ */ +/* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it + * is a mutation even when reached by a query. Ordinary query resolution uses + * the established blocking lease; manage_adr reads use the nonblocking + * recovery variant so an ADR lookup never waits behind a long reindex. */ +TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-guard-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); -/* The long-lived server ran the full index pipeline in-process on two background - * paths (session auto-index in mcp.c, watcher re-index in main.c). Worker-thread - * mimalloc heaps abandon pages at thread exit and mimalloc v3 - * (page_reclaim_on_free=0) does not reclaim them when the main thread later frees - * their blocks, so RSS ratchets across re-index cycles (#832). The fix routes both - * paths through cbm_mcp_index_run_supervised_path() — the SAME supervised worker - * subprocess the index_repository tool uses — so the child hands 100%% of its RSS - * back to the OS on exit. - * - * This guard proves the ROUTING: on a supervisor-marked host with the kill switch - * OFF, the shared entry the watcher/auto-index now call must (a) spawn a worker - * child (cbm_index_supervisor_spawn_count() increases) and (b) actually index the - * fixture (the worker child writes the Function node). RED on the unfixed - * in-process routing: it calls cbm_pipeline_run directly, so spawn_count is - * unchanged → IDX832_NO_SPAWN. */ -enum { - IDX832_OK = 0, - IDX832_NO_SPAWN = 51, /* spawn_count unchanged — routed in-process (RED) */ - IDX832_NULL_RESP = 52, /* supervised entry degraded to NULL */ - IDX832_NOT_INDEXED = 53, /* response/store lacks the indexed Function node */ - IDX832_SERVER_FAIL = 54, -}; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); -#ifndef _WIN32 /* helper used only by the POSIX fork harness below */ -static int idx832_supervised_route_check(const char *repo_dir) { - /* Become a supervisor host with the kill switch OFF — exactly the real MCP - * server's state. Done in the FORKED CHILD only (see the harness) so the - * parent test-runner's process-wide host mark stays clear and the #845 - * unmarked-embedder guard is unaffected. Bound the recovery loop + worker - * quiet-timeout so a stuck child cannot run long under the fork+alarm net. */ - cbm_index_supervisor_mark_host(); - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); - cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); + const char *project = "guard-corrupt-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - int spawns_before = cbm_index_supervisor_spawn_count(); - char *resp = cbm_mcp_index_run_supervised_path(repo_dir); - int spawns_after = cbm_index_supervisor_spawn_count(); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + cbm_mcp_server_t *query_srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(query_srv); + mcp_mutation_guard_probe_t query_probe = { + .observed_db_path = db_path, + }; + cbm_mcp_server_set_project_mutation_guard(query_srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &query_probe); + cbm_mcp_server_set_background_tasks(query_srv, false); - if (spawns_after == spawns_before) { - free(resp); - return IDX832_NO_SPAWN; /* the discriminating assertion: RED in-process */ - } - if (!resp) { - return IDX832_NULL_RESP; - } - bool indexed = response_contains_json_fragment(resp, "\"status\":\"indexed\""); + char *resp = cbm_mcp_handle_tool( + query_srv, "check_index_coverage", + "{\"project\":\"guard-corrupt-project\",\"paths\":[\"src/main.c\"]}"); free(resp); - if (!indexed) { - return IDX832_NOT_INDEXED; - } + cbm_mcp_server_free(query_srv); + char query_backup_path[CBM_SZ_1K]; + int query_backup_count = + mcp_find_corrupt_backups(cache, project, query_backup_path, sizeof(query_backup_path)); + bool query_live_removed = !cbm_file_exists(db_path); + bool query_backup_named = query_backup_path[0] != '\0'; + bool query_quarantined = + query_live_removed && query_backup_count == 1 && query_backup_named; - /* Store-level proof the worker child did real work: the Function node it wrote - * must be queryable from a fresh server reading the DB the child produced. */ - char *project = cbm_project_name_from_path(repo_dir); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - free(project); - return IDX832_SERVER_FAIL; - } - int code = IDX832_OK; - if (project) { - char q[512]; - snprintf(q, sizeof(q), - "{\"project\":\"%s\",\"name_pattern\":\"idx832_fn\",\"label\":\"Function\"}", - project); - char *sr = cbm_mcp_handle_tool(srv, "search_graph", q); - if (!sr || !strstr(sr, "idx832_fn")) { - code = IDX832_NOT_INDEXED; - } - free(sr); - } - cbm_mcp_server_free(srv); - free(project); - return code; + /* Replant the same deterministic corruption to exercise manage_adr's + * already-held lease independently from the query server above. */ + mcp_cleanup_corrupt_backups(cache, project); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + cbm_mcp_server_t *adr_srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(adr_srv); + mcp_mutation_guard_probe_t adr_probe = { + .observed_db_path = db_path, + }; + cbm_mcp_server_set_project_mutation_guard(adr_srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &adr_probe); + cbm_mcp_server_set_project_mutation_try_guard(adr_srv, + mcp_mutation_guard_probe_try_begin); + cbm_mcp_server_set_background_tasks(adr_srv, false); + resp = cbm_mcp_handle_tool(adr_srv, "manage_adr", + "{\"project\":\"guard-corrupt-project\",\"mode\":\"get\"}"); + free(resp); + cbm_mcp_server_free(adr_srv); + char adr_backup_path[CBM_SZ_1K]; + int adr_backup_count = + mcp_find_corrupt_backups(cache, project, adr_backup_path, sizeof(adr_backup_path)); + bool adr_quarantined = + !cbm_file_exists(db_path) && adr_backup_count == 1 && adr_backup_path[0] != '\0'; + + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(query_live_removed); + ASSERT_EQ(query_backup_count, 1); + ASSERT_TRUE(query_backup_named); + ASSERT_TRUE(query_quarantined); + ASSERT_EQ(query_probe.begin_count, 1); + ASSERT_EQ(query_probe.try_begin_count, 0); + ASSERT_EQ(query_probe.end_count, 1); + ASSERT_STR_EQ(query_probe.begin_projects[0], project); + ASSERT_STR_EQ(query_probe.end_projects[0], project); + ASSERT_TRUE(query_probe.db_exists_at_begin); + ASSERT_FALSE(query_probe.db_exists_at_end); + ASSERT_TRUE(adr_quarantined); + ASSERT_EQ(adr_probe.begin_count, 0); + ASSERT_EQ(adr_probe.try_begin_count, 1); + ASSERT_EQ(adr_probe.end_count, 1); + ASSERT_STR_EQ(adr_probe.try_begin_projects[0], project); + ASSERT_STR_EQ(adr_probe.end_projects[0], project); + ASSERT_TRUE(adr_probe.db_exists_at_begin); + ASSERT_FALSE(adr_probe.db_exists_at_end); + PASS(); } -#endif /* !_WIN32 */ -TEST(index_bg_paths_route_through_supervisor_issue832) { -#ifdef _WIN32 - /* The guard marks the process as a supervisor host, which cannot be undone. - * POSIX isolates that in a forked child; without fork we would pollute the - * shared test-runner (breaking the #845 unmarked-embedder guard). The routing - * logic is platform-independent and covered on POSIX CI; Windows containment - * is covered by the end-to-end crash-containment test. */ - SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)"); -#else - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx832-repo-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } +/* Integrity is checked before the lease is requested, but quarantine itself + * must fail closed when that lease is denied. In particular, a rejected query + * may not remove either a recoverable DB generation or its committed WAL. */ +TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-idx832-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); - } + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-denied-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); const char *saved_cache = getenv("CBM_CACHE_DIR"); char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */ + cbm_setenv("CBM_CACHE_DIR", cache, 1); - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def idx832_fn():\n return 'ok'\n", fp); - fclose(fp); + const char *project = "guard-corrupt-denied"; + char db_path[CBM_SZ_1K]; + char wal_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); + ASSERT_NOT_NULL(writer); + ASSERT_TRUE(cbm_file_exists(db_path)); + ASSERT_TRUE(cbm_file_exists(wal_path)); + + long db_len = 0; + long wal_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); + ASSERT_NOT_NULL(db_before); + ASSERT_NOT_NULL(wal_before); + ASSERT_TRUE(db_len > 0); + ASSERT_TRUE(wal_len > 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_background_tasks(srv, false); + char *resp = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"guard-corrupt-denied\",\"paths\":[\"src/main.c\"]}"); - int code = -1; - bool signalled = false; - int sig = 0; - fflush(NULL); - pid_t pid = fork(); - if (pid == 0) { - alarm(60); /* a stuck worker dies here instead of hanging the runner */ - _exit(idx832_supervised_route_check(tmp_dir)); - } - ASSERT_TRUE(pid > 0); - int status = 0; - (void)waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - code = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - signalled = true; - sig = WTERMSIG(status); - } + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); + bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + int artifact_count = mcp_count_corrupt_artifacts(cache, project); + int begin_count = probe.begin_count; + int end_count = probe.end_count; + bool guarded_project = begin_count == 1 && strcmp(probe.begin_projects[0], project) == 0; - char *project = cbm_project_name_from_path(tmp_dir); + free(resp); + cbm_mcp_server_free(srv); + free(db_before); + free(wal_before); + cbm_store_close(writer); + mcp_cleanup_corrupt_backups(cache, project); cleanup_project_db(cache, project); - free(project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - remove(src_path); cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - if (signalled) { - printf(" child killed by signal %d (alarm => worker hang)\n", sig); - } else if (code != IDX832_OK) { - printf(" child exit code %d (51=no spawn/in-process=RED, 52=null resp, " - "53=not indexed, 54=server fail)\n", - code); - } - ASSERT_FALSE(signalled); - ASSERT_EQ(code, IDX832_OK); + ASSERT_EQ(begin_count, 1); + ASSERT_EQ(end_count, 0); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(db_unchanged); + ASSERT_TRUE(wal_unchanged); + ASSERT_EQ(backup_count, 0); + ASSERT_EQ(artifact_count, 0); PASS(); -#endif } -/* ══════════════════════════════════════════════════════════════════ - * Parallel-only crash recovery (ms-typescript cascade fix) - * ══════════════════════════════════════════════════════════════════ */ - -/* The old recovery loop re-ran the worker SINGLE-THREADED to keep one exact - * crash marker. At scale that fell into the sequential crawl, was killed as - * a hang mid-pass, and the stale marker quarantined FOUR innocent - * ms-typescript fixtures, one 15-minute retry at a time. The reworked loop - * re-runs PARALLEL with a marker journal; a file is quarantined only when - * it is in-flight across two consecutive failed runs. +/* The OTHER half of the corrupt-store contract, and the reason the fixture + * above had to be changed. * - * This guard proves the CONTRACT: with an injected crasher among good - * files, the supervised index must (a) never spawn a single-threaded worker - * (cbm_index_supervisor_spawn_st_count stays 0 — RED on the old loop), - * (b) quarantine exactly the crasher, (c) leave the innocents indexed and - * NOT quarantined. */ -enum { - IDXPAR_OK = 0, - IDXPAR_ST_SPAWN = 61, /* single-threaded recovery spawn happened (RED) */ - IDXPAR_NULL_RESP = 62, /* supervised entry degraded to NULL */ - IDXPAR_NOT_INDEXED = 63, /* response lacks status indexed */ - IDXPAR_NO_QUARANTINE = 64, /* crasher missing from skipped[] */ - IDXPAR_INNOCENT_HIT = 65, /* a good file was quarantined/skipped */ - IDXPAR_GOOD_MISSING = 66, /* good file's Function absent from the store */ -}; + * A store whose ONLY defect is a cosmetic root_path is RETAINED, never + * quarantined: cbm_store_check_integrity_full reports it through + * path_only_failure (src/store/store.c:1519-1525) and resolve_store_internal + * takes the retain branch (src/mcp/mcp.c:4234). That is the #557 data-loss fix + * — deleting such a database destroyed intact node and edge data over a + * defect that queries, which key off project name rather than root_path, never + * observe. + * + * This pairs with the structurally-corrupt tests: together they pin BOTH + * parents' behavior, so neither can regress unnoticed. Without this test, + * "fixing" the guard tests by weakening the path_only classification would + * silently reintroduce #557 and every test would still pass. The guard must + * NOT be acquired here — retaining is not a mutation. */ +TEST(tool_cosmetic_root_path_store_is_retained_not_quarantined) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cosmetic-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); -#ifndef _WIN32 -static int idxpar_recovery_check(const char *repo_dir) { - cbm_index_supervisor_mark_host(); - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - /* Rounds needed: fail+record, fail+quarantine, clean. Generous cap. */ - cbm_setenv("CBM_INDEX_MAX_RESTARTS", "5", 1); - cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); - cbm_setenv("CBM_TEST_CRASH_ON", "idxpar_crasher", 1); + const char *project = "cosmetic-root-path"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - int st_before = cbm_index_supervisor_spawn_st_count(); - char *resp = cbm_mcp_index_run_supervised_path(repo_dir); - int st_after = cbm_index_supervisor_spawn_st_count(); - cbm_unsetenv("CBM_TEST_CRASH_ON"); + /* Valid store in every structural respect; only root_path is malformed. */ + cbm_store_t *writer = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(writer); + ASSERT_EQ(cbm_store_upsert_project(writer, project, "826"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_prepare_for_publish(writer), CBM_STORE_OK); + cbm_store_close(writer); - if (st_after != st_before) { - free(resp); - return IDXPAR_ST_SPAWN; /* discriminating assertion: RED on the old loop */ - } - if (!resp) { - return IDXPAR_NULL_RESP; - } - bool indexed = response_contains_json_fragment(resp, "\"status\":\"indexed\""); - bool crasher_skipped = strstr(resp, "idxpar_crasher.py") != NULL; - bool innocent_hit = - strstr(resp, "idxpar_good_a.py") != NULL || strstr(resp, "idxpar_good_b.py") != NULL; - free(resp); - if (!indexed) { - return IDXPAR_NOT_INDEXED; - } - if (!crasher_skipped) { - return IDXPAR_NO_QUARANTINE; - } - if (innocent_hit) { - return IDXPAR_INNOCENT_HIT; - } + long db_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + ASSERT_NOT_NULL(db_before); + ASSERT_TRUE(db_len > 0); - /* Store proof: an innocent's Function node exists. */ - char *project = cbm_project_name_from_path(repo_dir); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - int code = IDXPAR_OK; - if (srv && project) { - char q[512]; - snprintf(q, sizeof(q), - "{\"project\":\"%s\",\"name_pattern\":\"idxpar_good_fn\",\"label\":\"Function\"}", - project); - char *sr = cbm_mcp_handle_tool(srv, "search_graph", q); - if (!sr || !strstr(sr, "idxpar_good_fn")) { - code = IDXPAR_GOOD_MISSING; - } - free(sr); - } - if (srv) { - cbm_mcp_server_free(srv); - } - free(project); - return code; -} -#endif /* !_WIN32 */ + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*\"}", project); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", args); -/* #773: SIGABRT (invalid free in ts_stack_delete via - * cbm_destroy_thread_parser) on the SECOND index_repository in one server - * process, once both repos take the PARALLEL path (~30+ files). The - * supervisor masks this on the default MCP path (fresh worker process per - * index); the in-process pipeline — CBM_INDEX_SUPERVISOR=0, and every - * embedded/test consumer — dies. Forked child so the abort cannot kill the - * runner; ASan legs print the exact bad free. */ -enum { - IDX773_OK = 0, - IDX773_FIRST_FAILED = 71, /* first index didn't return indexed */ - IDX773_SECOND_FAILED = 72, /* second index didn't return indexed */ -}; + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + int begin_count = probe.begin_count; + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); -#ifndef _WIN32 -static void idx773_write_py_repo(const char *dir, int files, int variant) { - for (int i = 0; i < files; i++) { - char path[CBM_SZ_512]; - snprintf(path, sizeof(path), "%s/mod_%d_%03d.py", dir, variant, i); - FILE *f = fopen(path, "w"); - if (!f) { - continue; - } - fprintf(f, - "class Handler%d:\n" - " def run(self, x):\n" - " return self.helper(x) + %d\n" - " def helper(self, x):\n" - " for i in range(10):\n" - " x += i\n" - " return x\n" - "\n" - "def main_%d(x):\n" - " return Handler%d().run(x)\n", - i, i, i, i); - fclose(f); - } + free(resp); + cbm_mcp_server_free(srv); + free(db_before); + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + /* Retained: byte-identical, no backup produced, and the mutation guard was + * never claimed because nothing was mutated. */ + ASSERT_TRUE(db_unchanged); + ASSERT_EQ(backup_count, 0); + ASSERT_EQ(begin_count, 0); + PASS(); } -static int idx773_double_index_check(const char *dir_a, const char *dir_b) { - cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); +/* Read-side recovery is nonblocking: a held mutation lease returns an explicit + * retryable error while preserving the live database and recovery artifacts. */ +TEST(tool_manage_adr_corrupt_store_busy_is_retryable) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-adr-corrupt-busy-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-adr-corrupt-busy"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - return IDX773_FIRST_FAILED; - } - char args[CBM_SZ_512]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_a); - char *r1 = cbm_mcp_handle_tool(srv, "index_repository", args); - bool ok1 = r1 && strstr(r1, "indexed") != NULL; - free(r1); - if (!ok1) { - cbm_mcp_server_free(srv); - return IDX773_FIRST_FAILED; - } - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_b); - char *r2 = cbm_mcp_handle_tool(srv, "index_repository", args); /* SIGABRT here (RED) */ - bool ok2 = r2 && strstr(r2, "indexed") != NULL; - free(r2); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_try_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_try_guard(srv, mcp_mutation_guard_probe_try_begin); + + char *resp = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-corrupt-busy\",\"mode\":\"get\"}"); + bool retryable_busy = resp && strstr(resp, "project is busy; retry after indexing") && + response_contains_json_fragment(resp, "\"isError\":true"); + bool db_preserved = cbm_file_exists(db_path); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + + free(resp); cbm_mcp_server_free(srv); - return ok2 ? IDX773_OK : IDX773_SECOND_FAILED; + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(retryable_busy); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.try_begin_count, 1); + ASSERT_EQ(probe.end_count, 0); + ASSERT_TRUE(db_preserved); + ASSERT_EQ(backup_count, 0); + PASS(); } -#endif /* !_WIN32 */ -/* #898: the SEQUENTIAL pipeline emitted malformed JSON for brokered - * ASYNC_CALLS edges ("broker":"bullmq} — missing closing quote) and stored - * the RAW broker/method string as the synthesized Route node's properties - * (literally `bullmq` instead of {"broker":"bullmq"}). json_extract over - * those rows errors, generated-column indexes fail, and PRAGMA quick_check - * aborts with "malformed JSON" — which since the artifact deep-integrity - * check also means such caches are refused at import. The parallel path - * was correct; both pipelines must emit identical, valid JSON. */ -TEST(sequential_service_edge_props_are_valid_json_issue898) { - char tmp[CBM_SZ_256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_seq898_XXXXXX"); - if (!cbm_mkdtemp(tmp)) { - FAIL("mkdtemp failed"); - } - char cache[CBM_SZ_256]; - snprintf(cache, sizeof(cache), "/tmp/cbm_seq898_cache_XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp); - FAIL("cache mkdtemp failed"); - } +TEST(tool_manage_adr_corrupt_store_missing_try_guard_reports_configuration) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-adr-corrupt-config-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - char src_path[CBM_SZ_512]; - snprintf(src_path, sizeof(src_path), "%s/queue.py", tmp); - FILE *f = fopen(src_path, "w"); - ASSERT_NOT_NULL(f); - /* celery.Celery("tasks") resolves through the import map to a QN the - * service-pattern table classifies as ASYNC with broker "celery". */ - fputs("import celery\n" - "\n" - "def enqueue():\n" - " celery.Celery(\"tasks\")\n", - f); - fclose(f); + const char *project = "guard-adr-corrupt-config"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char args[CBM_SZ_512]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "indexed")); - free(resp); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - /* File-backed MCP stores are deliberately request-scoped so a sibling - * process can atomically replace the DB generation (and so Windows does - * not retain a replacement-blocking handle). Inspect the published DB - * through an independent query handle instead of relying on srv->store. */ - char *project = cbm_project_name_from_path(tmp); - ASSERT_NOT_NULL(project); - char db_path[CBM_SZ_512]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - cbm_store_t *store = cbm_store_open_path_query(db_path); - ASSERT_NOT_NULL(store); - struct sqlite3 *db = cbm_store_get_db(store); - ASSERT_NOT_NULL(db); + char *resp = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-corrupt-config\",\"mode\":\"get\"}"); + bool missing_try_guard = + resp && strstr(resp, "project recovery requires a nonblocking mutation guard") && + response_contains_json_fragment(resp, "\"isError\":true"); + bool db_preserved = cbm_file_exists(db_path); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); - /* Non-vacuous: the fixture must actually produce a brokered edge. */ - sqlite3_stmt *stmt = NULL; - ASSERT_EQ(sqlite3_prepare_v2(db, "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS';", -1, - &stmt, NULL), - SQLITE_OK); - ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); - int async_edges = sqlite3_column_int(stmt, 0); - sqlite3_finalize(stmt); - ASSERT_TRUE(async_edges >= 1); + free(resp); + cbm_mcp_server_free(srv); + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); - /* THE BUG: malformed properties on edges (broker quote) and Route nodes - * (raw string). Every properties blob must be valid JSON. */ - ASSERT_EQ(sqlite3_prepare_v2(db, - "SELECT count(*) FROM edges WHERE properties IS NOT NULL " - "AND properties != '' AND json_valid(properties)=0;", - -1, &stmt, NULL), - SQLITE_OK); - ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); - int bad_edges = sqlite3_column_int(stmt, 0); - sqlite3_finalize(stmt); - ASSERT_EQ(bad_edges, 0); + ASSERT_TRUE(missing_try_guard); + ASSERT_EQ(probe.begin_count, 0); + ASSERT_EQ(probe.try_begin_count, 0); + ASSERT_EQ(probe.end_count, 0); + ASSERT_TRUE(db_preserved); + ASSERT_EQ(backup_count, 0); + PASS(); +} - ASSERT_EQ(sqlite3_prepare_v2(db, - "SELECT count(*) FROM nodes WHERE properties IS NOT NULL " - "AND properties != '' AND json_valid(properties)=0;", - -1, &stmt, NULL), - SQLITE_OK); - ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); - int bad_nodes = sqlite3_column_int(stmt, 0); - sqlite3_finalize(stmt); - ASSERT_EQ(bad_nodes, 0); +/* Another session may publish a good generation while this query waits for + * the mutation lease. Cleanup must re-open and re-check the path after lease + * acquisition; quarantining based on the stale pre-wait handle loses the new + * generation and returns a false "not indexed" result. */ +TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-recheck-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - /* Pipeline parity: the broker must be extractable exactly like the - * parallel path emits it. */ - ASSERT_EQ(sqlite3_prepare_v2(db, - "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS' AND " - "json_extract(properties,'$.broker')='celery';", - -1, &stmt, NULL), - SQLITE_OK); - ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); - int brokered = sqlite3_column_int(stmt, 0); - sqlite3_finalize(stmt); - ASSERT_TRUE(brokered >= 1); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); - cbm_store_close(store); + const char *project = "guard-corrupt-recheck"; + const char *replacement_root = "/tmp/guard-corrupt-replacement"; + char db_path[CBM_SZ_1K]; + char replacement_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(replacement_path, sizeof(replacement_path), "%s/%s.replacement.db", cache, project); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + ASSERT_TRUE(mcp_make_valid_project_store_at(replacement_path, project, replacement_root)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_replacing_mutation_guard_t replacement = { + .replacement_path = replacement_path, + .live_path = db_path, + }; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_replacing_mutation_guard_begin, + mcp_replacing_mutation_guard_end, &replacement); + cbm_mcp_server_set_background_tasks(srv, false); + char *resp = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"guard-corrupt-recheck\",\"paths\":[\"src/main.c\"]}"); + bool response_used_replacement = + resp && !response_contains_json_fragment(resp, "\"isError\":true"); + free(resp); cbm_mcp_server_free(srv); + + cbm_store_t *check = cbm_store_open_path_query(db_path); + bool valid_generation = check && cbm_store_check_integrity(check); + cbm_project_t stored_project = {0}; + bool replacement_root_visible = + check && cbm_store_get_project(check, project, &stored_project) == CBM_STORE_OK && + stored_project.root_path && strcmp(stored_project.root_path, replacement_root) == 0; + cbm_project_free_fields(&stored_project); + cbm_store_close(check); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + bool live_exists = cbm_file_exists(db_path); + bool replacement_consumed = !cbm_file_exists(replacement_path); + int begin_count = replacement.guard.begin_count; + int end_count = replacement.guard.end_count; + bool guarded_project = begin_count == 1 && end_count == 1 && + strcmp(replacement.guard.begin_projects[0], project) == 0 && + strcmp(replacement.guard.end_projects[0], project) == 0; + bool replacement_attempted = replacement.replacement_attempted; + bool replacement_succeeded = replacement.replacement_succeeded; + + mcp_cleanup_corrupt_backups(cache, project); cleanup_project_db(cache, project); + cbm_unlink(replacement_path); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - free(project); - th_rmtree(cache); - cbm_unlink(src_path); - cbm_rmdir(tmp); + cbm_rmdir(cache); + + ASSERT_TRUE(replacement_attempted); + ASSERT_TRUE(replacement_succeeded); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(response_used_replacement); + ASSERT_TRUE(live_exists); + ASSERT_TRUE(replacement_consumed); + ASSERT_TRUE(valid_generation); + ASSERT_TRUE(replacement_root_visible); + ASSERT_EQ(backup_count, 0); PASS(); } -TEST(index_second_inprocess_run_survives_issue773) { -#ifdef _WIN32 - SKIP_PLATFORM("fork-isolated crash guard (POSIX-only)"); -#else - char dir_a[CBM_SZ_256]; - char dir_b[CBM_SZ_256]; - char cache[CBM_SZ_256]; - snprintf(dir_a, sizeof(dir_a), "/tmp/cbm-idx773a-XXXXXX"); - snprintf(dir_b, sizeof(dir_b), "/tmp/cbm-idx773b-XXXXXX"); - snprintf(cache, sizeof(cache), "/tmp/cbm-idx773c-XXXXXX"); - if (!cbm_mkdtemp(dir_a) || !cbm_mkdtemp(dir_b) || !cbm_mkdtemp(cache)) { - FAIL("mkdtemp failed"); - } +/* A fixed `.corrupt` destination is itself user recovery data. A later + * quarantine must retain it byte-for-byte and choose a distinct backup name + * rather than unlinking the previous incident before rename. */ +TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-unique-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - /* Trigger shape: run 1 small enough for the SEQUENTIAL path (parses on - * the calling thread, mimalloc epoch), run 2 large enough for the - * PARALLEL path (switches the global ts allocator to the slab). */ - idx773_write_py_repo(dir_a, 5, 0); - idx773_write_py_repo(dir_b, 60, 1); + const char *project = "guard-corrupt-unique"; + char db_path[CBM_SZ_1K]; + char existing_backup_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(existing_backup_path, sizeof(existing_backup_path), "%s.corrupt", db_path); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + ASSERT_EQ(th_write_file(existing_backup_path, "previous-backup-must-survive\n"), 0); + + long existing_len = 0; + unsigned char *existing_before = mcp_read_file_bytes(existing_backup_path, &existing_len); + ASSERT_NOT_NULL(existing_before); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_background_tasks(srv, false); + char *resp = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"guard-corrupt-unique\",\"paths\":[\"src/main.c\"]}"); + free(resp); + cbm_mcp_server_free(srv); - int code = -1; - bool signalled = false; - int sig = 0; - fflush(NULL); - pid_t pid = fork(); - if (pid == 0) { - alarm(180); /* generous: two full parallel indexes */ - _exit(idx773_double_index_check(dir_a, dir_b)); - } - ASSERT_TRUE(pid > 0); - int status = 0; - (void)waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - code = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - signalled = true; - sig = WTERMSIG(status); - } + bool existing_unchanged = + mcp_file_matches_snapshot(existing_backup_path, existing_before, existing_len); + free(existing_before); + char unique_backup_path[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unique_backup_path, sizeof(unique_backup_path)); + cbm_store_t *quarantined = + unique_backup_path[0] ? cbm_store_open_path_query(unique_backup_path) : NULL; + bool unique_backup_is_corrupt = quarantined && !cbm_store_check_integrity(quarantined); + cbm_store_close(quarantined); + bool live_removed = !cbm_file_exists(db_path); + int begin_count = probe.begin_count; + int end_count = probe.end_count; + bool guarded_project = begin_count == 1 && end_count == 1 && + strcmp(probe.begin_projects[0], project) == 0 && + strcmp(probe.end_projects[0], project) == 0; + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); + cbm_rmdir(cache); - if (signalled) { - printf(" child killed by signal %d (SIGABRT = the #773 invalid free)\n", sig); - } else if (code != IDX773_OK) { - printf(" child exit code %d (71=first index failed, 72=second failed)\n", code); - } - ASSERT_FALSE(signalled); - ASSERT_EQ(code, IDX773_OK); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(existing_unchanged); + ASSERT_EQ(backup_count, 2); + ASSERT_TRUE(unique_backup_path[0] != '\0'); + ASSERT_TRUE(unique_backup_is_corrupt); + ASSERT_TRUE(live_removed); PASS(); -#endif } -TEST(index_recovery_parallel_quarantines_crasher) { -#ifdef _WIN32 - SKIP_PLATFORM("parallel-recovery guard needs fork isolation (POSIX-only)"); -#else - char tmp_dir[CBM_SZ_256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idxpar-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - FAIL("mkdtemp failed"); - } - char cache[CBM_SZ_256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-idxpar-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - FAIL("mkdtemp cache failed"); - } +/* Deterministically fail immediately before atomic snapshot publication on + * every platform. The incomplete pending copy must be removed while the live + * DB and its committed WAL remain byte-for-byte untouched. */ +TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-publish-fail-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved_cache = getenv("CBM_CACHE_DIR"); - char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - char p1[CBM_SZ_512]; - char p2[CBM_SZ_512]; - char pc[CBM_SZ_512]; - snprintf(p1, sizeof(p1), "%s/idxpar_good_a.py", tmp_dir); - snprintf(p2, sizeof(p2), "%s/idxpar_good_b.py", tmp_dir); - snprintf(pc, sizeof(pc), "%s/idxpar_crasher.py", tmp_dir); - FILE *f = fopen(p1, "w"); - ASSERT_NOT_NULL(f); - fputs("def idxpar_good_fn():\n return 'ok'\n", f); - fclose(f); - f = fopen(p2, "w"); - ASSERT_NOT_NULL(f); - fputs("def idxpar_good_fn_b():\n return 'ok'\n", f); - fclose(f); - f = fopen(pc, "w"); - ASSERT_NOT_NULL(f); - fputs("def idxpar_crash_fn():\n return 'boom'\n", f); - fclose(f); + const char *project = "guard-corrupt-publish-fail"; + char db_path[CBM_SZ_1K]; + char wal_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); + ASSERT_NOT_NULL(writer); + ASSERT_TRUE(cbm_file_exists(wal_path)); - int code = -1; - bool signalled = false; - int sig = 0; - fflush(NULL); - pid_t pid = fork(); - if (pid == 0) { - alarm(120); /* generous: three supervised rounds + clean run */ - _exit(idxpar_recovery_check(tmp_dir)); - } - ASSERT_TRUE(pid > 0); - int status = 0; - (void)waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - code = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - signalled = true; - sig = WTERMSIG(status); - } + long db_len = 0; + long wal_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); + ASSERT_NOT_NULL(db_before); + ASSERT_NOT_NULL(wal_before); + ASSERT_TRUE(db_len > 0); + ASSERT_TRUE(wal_len > 0); - char *project = cbm_project_name_from_path(tmp_dir); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t guard = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &guard); + cbm_mcp_server_set_background_tasks(srv, false); + mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"}; + cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); + char *resp = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"guard-corrupt-publish-fail\",\"paths\":[\"src/main.c\"]}"); + + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); + bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + int artifact_count = mcp_count_corrupt_artifacts(cache, project); + int begin_count = guard.begin_count; + int end_count = guard.end_count; + bool guarded_project = begin_count == 1 && end_count == 1 && + strcmp(guard.begin_projects[0], project) == 0 && + strcmp(guard.end_projects[0], project) == 0; + bool failed_at_publish = + hook.call_count == 1 && strcmp(hook.steps[0], "before_snapshot_publish") == 0; + + free(resp); + cbm_mcp_server_free(srv); + free(db_before); + free(wal_before); + cbm_store_close(writer); + mcp_cleanup_corrupt_backups(cache, project); cleanup_project_db(cache, project); - free(project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - remove(p1); - remove(p2); - remove(pc); cbm_rmdir(cache); - cbm_rmdir(tmp_dir); - if (signalled) { - printf(" child killed by signal %d (alarm => recovery loop hang)\n", sig); - } else if (code != IDXPAR_OK) { - printf(" child exit code %d (61=ST spawn/RED, 62=null resp, 63=not indexed, " - "64=no quarantine, 65=innocent hit, 66=good missing)\n", - code); - } - ASSERT_FALSE(signalled); - ASSERT_EQ(code, IDXPAR_OK); + ASSERT_TRUE(failed_at_publish); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(db_unchanged); + ASSERT_TRUE(wal_unchanged); + ASSERT_EQ(backup_count, 0); + ASSERT_EQ(artifact_count, 0); PASS(); -#endif } -/* ══════════════════════════════════════════════════════════════════ - * AUTO_WATCH GATE (distilled from PR #625) - * - * Background watcher registration on session connect is gated by the - * `auto_watch` config key (default TRUE = existing behavior). - * ══════════════════════════════════════════════════════════════════ */ - -/* Drive the already-indexed connect path (initialize → maybe_auto_index → - * watcher registration) and return the resulting watch count. - * auto_watch_value: NULL leaves the key unset (exercises the default), - * otherwise the key is set to that value before initialize. - * Returns a negative code on fixture setup failure. */ -static int auto_watch_connect_watch_count(const char *auto_watch_value) { +/* Once the recovery snapshot is atomically visible, a crash/failure before + * deleting the live generation may leave both copies. The live DB/WAL must be + * unchanged, and the published backup must already contain committed WAL data + * as one self-contained SQLite database. */ +TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-autowatch-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - return -1; - } - - char repodir[512]; - snprintf(repodir, sizeof(repodir), "%s/repo", cache); - if (th_mkdir_p(repodir) != 0) { - th_rmtree(cache); - return -2; - } - - /* Same derivation detect_session uses on the cwd — realpath-based, so - * the name matches even where /tmp is a symlink (macOS). */ - char *project = cbm_project_name_from_path(repodir); - if (!project) { - th_rmtree(cache); - return -3; - } - - /* Pre-create /.db so maybe_auto_index takes the - * "already indexed" branch — the watcher-registration site under test. */ - char db_path[1024]; - snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - if (th_write_file(db_path, "") != 0) { - free(project); - th_rmtree(cache); - return -4; - } - free(project); + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-after-publish-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; cbm_setenv("CBM_CACHE_DIR", cache, 1); - char old_cwd[1024]; - if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repodir) != 0) { - restore_cache_dir(saved_copy); - free(saved_copy); - th_rmtree(cache); - return -5; - } + const char *project = "guard-corrupt-after-publish"; + char db_path[CBM_SZ_1K]; + char wal_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); + ASSERT_NOT_NULL(writer); + ASSERT_TRUE(cbm_file_exists(wal_path)); - int count = -6; - cbm_config_t *cfg = cbm_config_open(cache); - cbm_store_t *wstore = cbm_store_open_memory(); - cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL; - if (cfg && watcher) { - if (auto_watch_value) { - cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, auto_watch_value); - } + long db_len = 0; + long wal_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); + ASSERT_NOT_NULL(db_before); + ASSERT_NOT_NULL(wal_before); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (srv) { - cbm_mcp_server_set_watcher(srv, watcher); - cbm_mcp_server_set_config(srv, cfg); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); - free(resp); - count = cbm_watcher_watch_count(watcher); - cbm_mcp_server_free(srv); - } - } + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t guard = {0}; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &guard); + cbm_mcp_server_set_background_tasks(srv, false); + mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"}; + cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); + char *resp = cbm_mcp_handle_tool( + srv, "check_index_coverage", + "{\"project\":\"guard-corrupt-after-publish\",\"paths\":[\"src/main.c\"]}"); - if (watcher) { - cbm_watcher_free(watcher); - } - if (wstore) { - cbm_store_close(wstore); - } - if (cfg) { - cbm_config_close(cfg); + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); + bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); + char backup_path[CBM_SZ_1K]; + int backup_count = mcp_find_corrupt_backups(cache, project, backup_path, sizeof(backup_path)); + int artifact_count = mcp_count_corrupt_artifacts(cache, project); + /* Prove the WAL content reached the snapshot by reading back the sentinel + * row, which is what guard_wal_sentinel exists for: the fixture writes it + * under PRAGMA wal_autocheckpoint=0, so it lives ONLY in the WAL until a + * checkpoint. Finding it in the backup proves the quarantine published a + * complete, checkpointed snapshot rather than copying the bare .db. + * + * This previously read the projects row and compared root_path to "826". + * That no longer works and could not: the fixture must DROP the projects + * table to be structurally corrupt at all, because a readable projects + * table with a malformed root_path is the COSMETIC case that + * cbm_store_check_integrity_full reports via path_only_failure and that + * resolve_store_internal deliberately RETAINS (#557). A store that reaches + * quarantine therefore cannot still have a readable projects row — the two + * requirements are mutually exclusive. The sentinel proves the same + * property without that contradiction. */ + cbm_store_t *snapshot = backup_path[0] ? cbm_store_open_path_query(backup_path) : NULL; + bool recovered_wal_project = false; + if (snapshot) { + sqlite3 *snap_db = cbm_store_get_db(snapshot); + sqlite3_stmt *sentinel = NULL; + if (snap_db && sqlite3_prepare_v2(snap_db, "SELECT value FROM guard_wal_sentinel LIMIT 1;", + -1, &sentinel, NULL) == SQLITE_OK) { + if (sqlite3_step(sentinel) == SQLITE_ROW) { + const char *value = (const char *)sqlite3_column_text(sentinel, 0); + recovered_wal_project = value && strcmp(value, "committed") == 0; + } + sqlite3_finalize(sentinel); + } } + cbm_store_close(snapshot); + char backup_wal[CBM_SZ_2K]; + char backup_shm[CBM_SZ_2K]; + snprintf(backup_wal, sizeof(backup_wal), "%s-wal", backup_path); + snprintf(backup_shm, sizeof(backup_shm), "%s-shm", backup_path); + bool snapshot_self_contained = !cbm_file_exists(backup_wal) && !cbm_file_exists(backup_shm); + bool hook_order = hook.call_count == 2 && + strcmp(hook.steps[0], "before_snapshot_publish") == 0 && + strcmp(hook.steps[1], "after_snapshot_publish") == 0; + bool guard_balanced = guard.begin_count == 1 && guard.try_begin_count == 0 && + guard.end_count == 1 && strcmp(guard.begin_projects[0], project) == 0 && + strcmp(guard.end_projects[0], project) == 0; - (void)cbm_chdir(old_cwd); - restore_cache_dir(saved_copy); - free(saved_copy); - th_rmtree(cache); - return count; -} + free(resp); + cbm_mcp_server_free(srv); + free(db_before); + free(wal_before); + cbm_store_close(writer); + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); -/* Default (key unset) → watcher registered on connect. Guards the - * no-behavior-change promise of the auto_watch gate: existing users keep - * background auto-sync without touching config. */ -TEST(mcp_auto_watch_default_registers_watcher_on_connect) { - int count = auto_watch_connect_watch_count(NULL); - if (count < 0) { - PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */ - } - ASSERT_EQ(count, 1); + ASSERT_TRUE(hook_order); + ASSERT_TRUE(guard_balanced); + ASSERT_TRUE(db_unchanged); + ASSERT_TRUE(wal_unchanged); + ASSERT_EQ(backup_count, 1); + ASSERT_EQ(artifact_count, 1); + ASSERT_TRUE(recovered_wal_project); + ASSERT_TRUE(snapshot_self_contained); PASS(); } -/* auto_watch=false → NO watcher registered on connect. RED on pre-gate code - * (registration was unconditional and the key did not exist). */ -TEST(mcp_auto_watch_false_skips_watcher_on_connect) { - int count = auto_watch_connect_watch_count("false"); - if (count < 0) { - PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */ - } - ASSERT_EQ(count, 0); +/* detect_changes owns argv-child stdout through regular temporary files. Every + * success, validation error, and injected pre-spawn rejection must restore the + * pre-call artifact count. The hook also proves every Git operation reaches the + * contained argv helper; a raw popen regression bypasses it and fails here. */ +TEST(detect_changes_node_in_hunks_overlap_issue1363) { + cbm_changed_hunk_t hunks[] = { + {.path = "pkg/mod.py", .start_line = 10, .end_line = 12}, + {.path = "pkg/other.py", .start_line = 1, .end_line = 1}, + }; + cbm_node_t inside = {.start_line = 8, .end_line = 15}; + cbm_node_t exact = {.start_line = 10, .end_line = 12}; + cbm_node_t touches_edge = {.start_line = 12, .end_line = 20}; + cbm_node_t before = {.start_line = 1, .end_line = 9}; + cbm_node_t after = {.start_line = 13, .end_line = 20}; + + ASSERT(cbm_detect_node_in_hunks(&inside, hunks, PAIR_LEN, "pkg/mod.py")); + ASSERT(cbm_detect_node_in_hunks(&exact, hunks, PAIR_LEN, "pkg/mod.py")); + ASSERT(cbm_detect_node_in_hunks(&touches_edge, hunks, PAIR_LEN, "pkg/mod.py")); + ASSERT(!cbm_detect_node_in_hunks(&before, hunks, PAIR_LEN, "pkg/mod.py")); + ASSERT(!cbm_detect_node_in_hunks(&after, hunks, PAIR_LEN, "pkg/mod.py")); + ASSERT(!cbm_detect_node_in_hunks(&exact, hunks, PAIR_LEN, "pkg/unrelated.py")); PASS(); } -/* ══════════════════════════════════════════════════════════════════ - * #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index - * watcher registration (keystone × #849 merge interaction) - * ══════════════════════════════════════════════════════════════════ */ +/* A same-line-count edit inside one top-level function must seed only that + * function. Hunk parsing adds O(diff bytes + hunk count) time and memory up + * to the named safety ceiling; seed filtering remains linear in candidate + * definitions times relevant hunks and does not reduce result recall. */ +TEST(detect_changes_seeds_only_touched_symbol_issue1363) { + char repo[512]; + snprintf(repo, sizeof(repo), "%s/cbm-detect-seed-scope-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); -/* #849 routed ALL watcher registration through register_watcher_if_enabled() - * (auto_watch gate). The #832 keystone then added a SECOND registration site in - * autoindex_thread's supervised-success branch, but wired it as a DIRECT - * cbm_watcher_watch() guarded only by `if (srv->watcher)` — srv->watcher is set - * unconditionally, so that guard does NOT honour `config set auto_watch false`. - * The above tests only cover the already-indexed on-connect path - * (register_watcher_if_enabled); this guard covers the fresh-index SUPERVISED - * autoindex_thread branch that #832 introduced. - * - * Drive the real public entry initialize → maybe_auto_index → autoindex_thread on - * a supervisor-marked host (kill switch off) with a FRESH project (no prior .db) - * and auto_watch=false. cbm_mcp_server_free() joins the autoindex thread, so the - * (buggy or gated) registration decision has run before we read the watch count. - * - * RED on the unfixed ungated block: the supervised success branch calls - * cbm_watcher_watch() unconditionally → watch_count == 1 → IDX853_WATCHER_REGISTERED. - * GREEN once it calls register_watcher_if_enabled() → auto_watch_off skip → 0. - * spawn_count is asserted to have advanced so the assertion cannot pass vacuously - * (i.e. green only because the supervised branch was never entered). */ -enum { - IDX853_OK = 0, /* watch_count==0, supervised branch ran → GREEN */ - IDX853_WATCHER_REGISTERED = 61, /* watch_count==1 → RED: ungated cbm_watcher_watch */ - IDX853_NO_SPAWN = 62, /* spawn_count unchanged → supervised path not exercised */ - IDX853_SETUP_FAIL = 63, /* config/watcher/server/cwd setup failed */ - IDX853_BAD_COUNT = 64, /* unexpected watch_count (<0 or >1) */ -}; + char src[600]; + snprintf(src, sizeof(src), "%s/mod.py", repo); + ASSERT_EQ(th_write_file(src, "def foo():\n" + " x = 1\n" + " return x\n" + "\n" + "\n" + "def bar():\n" + " y = 2\n" + " return y\n"), + 0); + ASSERT_TRUE(mcp_test_init_committed_repo(repo, "mod.py")); -#ifndef _WIN32 /* helper used only by the POSIX fork harness below */ -static int idx853_supervised_autowatch_check(const char *repo_dir, const char *cache_dir) { - /* Become a supervisor host with the kill switch OFF — the real prod MCP - * server's state. Done in the FORKED CHILD only (see harness) so the parent - * test-runner's process-wide host mark stays clear (#845 invariant). Bound the - * worker so a stuck spawn cannot run long under the fork+alarm net. */ - cbm_index_supervisor_mark_host(); - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); - cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char idx_args[700]; + snprintf(idx_args, sizeof(idx_args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", repo); + char *idx_resp = cbm_mcp_handle_tool(srv, "index_repository", idx_args); + ASSERT_NOT_NULL(idx_resp); + ASSERT_NULL(strstr(idx_resp, "\"isError\":true")); + free(idx_resp); - cbm_config_t *cfg = cbm_config_open(cache_dir); - cbm_store_t *wstore = cbm_store_open_memory(); - cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL; - if (!cfg || !watcher) { - if (watcher) { - cbm_watcher_free(watcher); - } - if (wstore) { - cbm_store_close(wstore); - } - if (cfg) { - cbm_config_close(cfg); - } - return IDX853_SETUP_FAIL; - } - /* auto_index=true → maybe_auto_index launches autoindex_thread for the fresh - * project; auto_watch=false → the gate this guard exercises. */ - cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true"); - cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "false"); + ASSERT_EQ(th_write_file(src, "def foo():\n" + " x = 11\n" + " return x\n" + "\n" + "\n" + "def bar():\n" + " y = 2\n" + " return y\n"), + 0); - /* detect_session derives session_root/session_project from the cwd. */ - char old_cwd[1024]; - if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repo_dir) != 0) { - cbm_watcher_free(watcher); - cbm_store_close(wstore); - cbm_config_close(cfg); - return IDX853_SETUP_FAIL; - } + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); + char dc_args[700]; + snprintf(dc_args, sizeof(dc_args), "{\"project\":\"%s\",\"depth\":1}", project); + char *dc_resp = cbm_mcp_handle_tool(srv, "detect_changes", dc_args); + ASSERT_NOT_NULL(dc_resp); + ASSERT_NOT_NULL(strstr(dc_resp, "seed_symbols: 1\\n")); + ASSERT_NULL(strstr(dc_resp, "bar")); - int spawns_before = cbm_index_supervisor_spawn_count(); - int code = IDX853_SETUP_FAIL; + free(dc_resp); + free(project); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); +} + +/* An import-only hunk overlaps no definition. In that case the precision + * optimization must fall back to whole-file seeds, preserving completeness + * with the same asymptotic traversal bound as the pre-hunk implementation. */ +TEST(detect_changes_zero_overlap_falls_back_issue1363) { + char repo[512]; + snprintf(repo, sizeof(repo), "%s/cbm-detect-zero-overlap-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(repo)); + + char src[600]; + snprintf(src, sizeof(src), "%s/mod.py", repo); + ASSERT_EQ(th_write_file(src, "import os\n" + "\n" + "\n" + "def foo():\n" + " return 1\n" + "\n" + "\n" + "def bar():\n" + " return 2\n"), + 0); + ASSERT_TRUE(mcp_test_init_committed_repo(repo, "mod.py")); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (srv) { - cbm_mcp_server_set_watcher(srv, watcher); - cbm_mcp_server_set_config(srv, cfg); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); - free(resp); - /* free() joins the autoindex thread → the supervised worker has finished - * and the registration decision (buggy or gated) has executed. */ - cbm_mcp_server_free(srv); + ASSERT_NOT_NULL(srv); + char idx_args[700]; + snprintf(idx_args, sizeof(idx_args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", repo); + char *idx_resp = cbm_mcp_handle_tool(srv, "index_repository", idx_args); + ASSERT_NOT_NULL(idx_resp); + ASSERT_NULL(strstr(idx_resp, "\"isError\":true")); + free(idx_resp); - int spawns_after = cbm_index_supervisor_spawn_count(); - int watch_count = cbm_watcher_watch_count(watcher); + ASSERT_EQ(th_write_file(src, "import os, sys\n" + "\n" + "\n" + "def foo():\n" + " return 1\n" + "\n" + "\n" + "def bar():\n" + " return 2\n"), + 0); - if (spawns_after == spawns_before) { - code = IDX853_NO_SPAWN; /* supervised branch never ran — not a valid probe */ - } else if (watch_count == 1) { - code = IDX853_WATCHER_REGISTERED; /* the discriminating RED assertion */ - } else if (watch_count == 0) { - code = IDX853_OK; - } else { - code = IDX853_BAD_COUNT; - } - } + char *project = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(project); + char dc_args[700]; + snprintf(dc_args, sizeof(dc_args), "{\"project\":\"%s\",\"depth\":1}", project); + char *dc_resp = cbm_mcp_handle_tool(srv, "detect_changes", dc_args); + ASSERT_NOT_NULL(dc_resp); + ASSERT_NOT_NULL(strstr(dc_resp, "seed_symbols: 2\\n")); - (void)cbm_chdir(old_cwd); - cbm_watcher_free(watcher); - cbm_store_close(wstore); - cbm_config_close(cfg); - return code; + free(dc_resp); + free(project); + cbm_mcp_server_free(srv); + th_rmtree(repo); + PASS(); } -#endif /* !_WIN32 */ - -TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853) { -#ifdef _WIN32 - /* Marks the process as a supervisor host (irreversible); POSIX isolates that - * in a forked child. The gate logic is platform-independent and covered on - * POSIX CI. */ - SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)"); -#else - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx853-repo-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-idx853-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); - } +TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) { + char cache[512]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-detect-contained-XXXXXX", cbm_tmpdir()); + bool cache_created = cbm_mkdtemp(cache) != NULL; const char *saved_cache = getenv("CBM_CACHE_DIR"); char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */ - - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def idx853_fn():\n return 'ok'\n", fp); - fclose(fp); + bool environment_ready = cache_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; - int code = -1; - bool signalled = false; - int sig = 0; - fflush(NULL); - pid_t pid = fork(); - if (pid == 0) { - alarm(60); /* a stuck worker dies here instead of hanging the runner */ - _exit(idx853_supervised_autowatch_check(tmp_dir, cache)); - } - ASSERT_TRUE(pid > 0); - int status = 0; - (void)waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - code = WEXITSTATUS(status); - } else if (WIFSIGNALED(status)) { - signalled = true; - sig = WTERMSIG(status); + char root[CBM_SZ_4K] = {0}; + int root_length = snprintf(root, sizeof(root), "%s/repo", cache); + bool root_ready = environment_ready && root_length > 0 && + (size_t)root_length < sizeof(root) && th_mkdir_p(root) == 0; + char source_path[CBM_SZ_4K] = {0}; + int source_length = + root_ready ? snprintf(source_path, sizeof(source_path), "%s/main.c", root) : -1; + root_ready = root_ready && source_length > 0 && (size_t)source_length < sizeof(source_path) && + th_write_file(source_path, "int main(void) { return 0; }\n") == 0 && + mcp_test_init_committed_repo(root, "main.c"); + /* Leave a real unstaged hunk so the symbols request allocates its hunk + * array before the command hook rejects the later merge-base operation. */ + root_ready = root_ready && + th_write_file(source_path, "int main(void) { return 1; }\n") == 0; + const char *project = "detect-contained-project"; + cbm_mcp_server_t *srv = environment_ready && root_ready ? cbm_mcp_server_new(NULL) : NULL; + bool server_ready = srv != NULL; + cbm_store_t *store = srv ? cbm_mcp_server_store(srv) : NULL; + bool project_ready = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; + mcp_command_hook_probe_t command_probe = {.reject_merge_base = true}; + if (project_ready) { + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe); } + int artifacts_before = + mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-git-"); - char *project = cbm_project_name_from_path(tmp_dir); - cleanup_project_db(cache, project); - free(project); + char *invalid_response = + project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"detect-contained-project\"," + "\"base_branch\":\"HEAD\",\"scope\":\"files\"," + "\"direction\":\"sideways\"}") + : NULL; + bool invalid_rejected = invalid_response && strstr(invalid_response, "invalid direction"); + int artifacts_after_error = + invalid_response + ? mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-git-") + : -1; + + char *rejected_response = + project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"detect-contained-project\"," + "\"base_branch\":\"HEAD\",\"scope\":\"symbols\"}") + : NULL; + bool containment_rejected = + rejected_response && strstr(rejected_response, "contained command could not complete"); + int artifacts_after_rejection = + rejected_response + ? mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-git-") + : -1; + + command_probe.reject_merge_base = false; + char *success_response = + project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"detect-contained-project\"," + "\"base_branch\":\"HEAD\",\"scope\":\"files\"}") + : NULL; + bool merge_base_reported = success_response && strstr(success_response, "merge_base"); + int artifacts_after_success = + success_response + ? mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-git-") + : -1; + + free(invalid_response); + free(rejected_response); + free(success_response); + cbm_mcp_server_free(srv); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); - remove(src_path); - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); + bool cleaned = !cache_created || th_rmtree(cache) == 0; - if (signalled) { - printf(" child killed by signal %d (alarm => worker hang)\n", sig); - } else if (code != IDX853_OK) { - printf(" child exit code %d (61=watcher registered under auto_watch=false=RED, " - "62=no spawn, 63=setup fail, 64=bad count)\n", - code); - } - ASSERT_FALSE(signalled); - ASSERT_EQ(code, IDX853_OK); + ASSERT_TRUE(cache_created); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(root_ready); + ASSERT_TRUE(server_ready); + ASSERT_TRUE(project_ready); + ASSERT_TRUE(artifacts_before >= 0); + ASSERT_TRUE(invalid_rejected); + ASSERT_EQ(artifacts_after_error, artifacts_before); + ASSERT_TRUE(containment_rejected); + ASSERT_EQ(artifacts_after_rejection, artifacts_before); + ASSERT_TRUE(merge_base_reported); + ASSERT_EQ(artifacts_after_success, artifacts_before); + /* The symbols request reaches two additional hunk-diff operations before + * merge-base; the file-only requests each reach two diff operations. Keep + * operation classes separate so an omitted/duplicated child cannot hide in + * an aggregate count. */ + ASSERT_EQ(command_probe.diff_calls, 8); + ASSERT_EQ(command_probe.status_calls, 3); + ASSERT_EQ(command_probe.merge_base_calls, 2); + ASSERT_TRUE(cleaned); PASS(); -#endif } -/* The containment guard both MCP file-read sinks route through - * (resolve_snippet_source for get_code_snippet, attach_result_source for - * search_code). A result path that resolves outside the indexed project root - * — via a `..` segment or a followed symlink/junction — must be rejected so - * its contents never reach a tool response. */ -extern bool cbm_path_within_root(const char *root_path, const char *abs_path); +/* Reproduce-first: one MCP session caches a query connection to generation A, + * then the fixture models an independent writer publishing generation B by + * atomically replacing the project DB at the same cache path. Because + * resolve_store() keys its cache only by project name, the next query can reuse + * stale generation A. It must instead return generation B. */ +TEST(query_store_reopens_after_database_replacement) { + static const char project[] = "cbm-store-generation-refresh"; + static const char active_filename[] = "cbm-store-generation-refresh.db"; + static const char staged_filename[] = "cbm-store-generation-next.db"; -TEST(mcp_path_within_root_rejects_escape) { -#ifdef _WIN32 - char root[512]; - char outside[512]; - snprintf(root, sizeof(root), "%s/cbm_pwr_root_XXXXXX", cbm_tmpdir()); - snprintf(outside, sizeof(outside), "%s/cbm_pwr_outside_XXXXXX", cbm_tmpdir()); - ASSERT_NOT_NULL(cbm_mkdtemp(root)); - ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + char cache[512]; + snprintf(cache, sizeof(cache), "%s/cbm-store-generation-XXXXXX", cbm_tmpdir()); + bool cache_ready = cbm_mkdtemp(cache) != NULL; + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + if (cache_ready) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); + } - char inside[700]; - char target[700]; - char junction[700]; - char linked_target[900]; - snprintf(inside, sizeof(inside), "%s/inside.c", root); - snprintf(target, sizeof(target), "%s/outside.c", outside); - snprintf(junction, sizeof(junction), "%s/escape", root); - snprintf(linked_target, sizeof(linked_target), "%s/outside.c", junction); - FILE *fp = cbm_fopen(inside, "w"); - ASSERT_NOT_NULL(fp); - fputs("int inside;\n", fp); - fclose(fp); - fp = cbm_fopen(target, "w"); - ASSERT_NOT_NULL(fp); - fputs("int outside;\n", fp); - fclose(fp); + bool generation_a_ready = + cache_ready && issue704_make_db(cache, active_filename, project, "GenerationA"); + cbm_mcp_server_t *srv = generation_a_ready ? cbm_mcp_server_new(NULL) : NULL; + bool server_ready = srv != NULL; - /* cbm_tmpdir() can expose the MSYS spelling C:/msys64/...; cmd's mklink - * builtin treats the slash before "msys64" as another option delimiter. - * Native backslashes are required only at this cmd.exe fixture boundary. */ - char junction_native[sizeof(junction)]; - char outside_native[sizeof(outside)]; - snprintf(junction_native, sizeof(junction_native), "%s", junction); - snprintf(outside_native, sizeof(outside_native), "%s", outside); - for (char *cursor = junction_native; *cursor; cursor++) { - if (*cursor == '/') { - *cursor = '\\'; - } + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"name_pattern\":\".*Generation.*\",\"limit\":10}", project); + char *before = srv ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL; + bool saw_generation_a = before && strstr(before, "GenerationA") != NULL; + + bool generation_b_ready = + cache_ready && issue704_make_db(cache, staged_filename, project, "GenerationB"); + char active_path[700]; + char staged_path[700]; + snprintf(active_path, sizeof(active_path), "%s/%s", cache, active_filename); + snprintf(staged_path, sizeof(staged_path), "%s/%s", cache, staged_filename); + bool replaced = generation_b_ready && cbm_rename_replace(staged_path, active_path) == 0; + + char *after = (srv && replaced) ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL; + bool saw_generation_b = after && strstr(after, "GenerationB") != NULL; + bool retained_generation_a = after && strstr(after, "GenerationA") != NULL; + + free(before); + free(after); + if (srv) { + cbm_mcp_server_free(srv); } - for (char *cursor = outside_native; *cursor; cursor++) { - if (*cursor == '/') { - *cursor = '\\'; - } + if (cache_ready) { + cleanup_project_db(cache, project); + cleanup_project_db(cache, "cbm-store-generation-next"); + cbm_rmdir(cache); } - const char *junction_argv[] = {"cmd.exe", "/d", "/c", "mklink", "/J", - junction_native, outside_native, NULL}; - bool linked = cbm_exec_no_shell(junction_argv) == 0; + restore_cache_dir(saved_copy); + free(saved_copy); - ASSERT_TRUE(linked); - ASSERT_TRUE(cbm_path_within_root(root, inside)); - ASSERT_FALSE(cbm_path_within_root(root, target)); - ASSERT_FALSE(cbm_path_within_root(root, linked_target)); - - char case_alias[sizeof(root)]; - snprintf(case_alias, sizeof(case_alias), "%s", root); - char *leaf = strrchr(case_alias, '/'); - char *backslash_leaf = strrchr(case_alias, '\\'); - if (!leaf || (backslash_leaf && backslash_leaf > leaf)) { - leaf = backslash_leaf; - } - leaf = leaf ? leaf + 1 : case_alias; - if (*leaf >= 'a' && *leaf <= 'z') { - *leaf = (char)(*leaf - 'a' + 'A'); - } else if (*leaf >= 'A' && *leaf <= 'Z') { - *leaf = (char)(*leaf - 'A' + 'a'); - } - ASSERT_TRUE(cbm_path_within_root(case_alias, inside)); - - char drive_root[] = {root[0], ':', '\\', '\0'}; - ASSERT_TRUE(((root[0] >= 'A' && root[0] <= 'Z') || (root[0] >= 'a' && root[0] <= 'z')) && - root[1] == ':'); - ASSERT_TRUE(cbm_path_within_root(drive_root, inside)); - - cbm_rmdir(junction); - cbm_unlink(inside); - cbm_unlink(target); - cbm_rmdir(root); - cbm_rmdir(outside); + ASSERT_TRUE(cache_ready); + ASSERT_TRUE(generation_a_ready); + ASSERT_TRUE(server_ready); + ASSERT_TRUE(saw_generation_a); + ASSERT_TRUE(generation_b_ready); + ASSERT_TRUE(replaced); + ASSERT_TRUE(saw_generation_b); + ASSERT_FALSE(retained_generation_a); PASS(); -#else - char root[512]; - snprintf(root, sizeof(root), "%s/cbm_pwr_XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(root)) { - FAIL("cbm_mkdtemp failed"); - } - char inside[700]; - snprintf(inside, sizeof(inside), "%s/inside.c", root); - FILE *fp = fopen(inside, "w"); - ASSERT_NOT_NULL(fp); - fputs("int x;\n", fp); - fclose(fp); +} - /* The abs_path a sink builds for an in-root result stays contained; a `..` - * escape to an existing outside file (/etc/hosts) resolves out and must be - * rejected. */ - char escape[900]; - snprintf(escape, sizeof(escape), "%s/../../../../etc/hosts", root); - ASSERT_TRUE(cbm_path_within_root(root, inside)); - ASSERT_FALSE(cbm_path_within_root(root, escape)); - ASSERT_FALSE(cbm_path_within_root(root, "/etc/hosts")); - ASSERT_TRUE(cbm_path_within_root("/", "/etc/hosts")); +TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery) { + char response[] = "{\"status\":\"indexed\"}"; + cbm_index_worker_result_t result = { + .outcome = CBM_PROC_CLEAN, + .exit_code = 0, + .tree_quiesced = true, + .response = response, + }; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), CBM_MCP_SUPERVISED_RESULT_SUCCESS); - remove(inside); - cbm_rmdir(root); - PASS(); -#endif -} + result.cancellation_requested = true; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); + result.cancellation_requested = false; + result.tree_quiesced = false; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); + result.tree_quiesced = true; + result.supervision_failed = true; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); -/* base_branch is spliced into a `git diff --name-only ""...HEAD` command; - * a value starting with '-' would be taken by git as an option (e.g. - * --output= writes the diff to an arbitrary file) rather than a ref. It - * must be rejected up front, alongside the shell-metacharacter check. */ -TEST(detect_changes_rejects_option_like_base_branch) { - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"detect_changes\"," - "\"arguments\":{\"project\":\"p\",\"base_branch\":\"--output=/tmp/cbm_pwn\"}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "invalid characters")); - free(resp); - cbm_mcp_server_free(srv); + result.supervision_failed = false; + result.outcome = CBM_PROC_CRASH; + result.response = NULL; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE); + ASSERT_EQ(cbm_mcp_supervised_result_disposition(-1, &result), + CBM_MCP_SUPERVISED_RESULT_FALLBACK); PASS(); } -TEST(detect_changes_rejects_windows_cmd_metacharacters_in_base_branch) { +TEST(index_supervisor_start_failure_is_fail_closed_in_real_host) { #ifdef _WIN32 - const char *const branches[] = {"topic%PATH%", "topic!name!", "topic^name"}; - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - for (size_t i = 0; i < sizeof(branches) / sizeof(branches[0]); i++) { - char request[512]; - snprintf(request, sizeof(request), - "{\"jsonrpc\":\"2.0\",\"id\":78,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"detect_changes\"," - "\"arguments\":{\"project\":\"p\",\"base_branch\":\"%s\"}}}", - branches[i]); - char *response = cbm_mcp_server_handle(srv, request); - ASSERT_NOT_NULL(response); - ASSERT_NOT_NULL(strstr(response, "base_branch contains invalid characters")); - free(response); - } - cbm_mcp_server_free(srv); - PASS(); + SKIP_PLATFORM("immutable host mark needs fork isolation (POSIX-only)"); #else - SKIP_PLATFORM("cmd.exe interpolation validation runs on Windows"); -#endif -} + char repo_dir[CBM_SZ_1K]; + char cache_dir[CBM_SZ_1K]; + (void)snprintf(repo_dir, sizeof(repo_dir), "%s/cbm-idx-failclosed-repo-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache_dir, sizeof(cache_dir), "%s/cbm-idx-failclosed-cache-XXXXXX", + cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(repo_dir)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache_dir)); -TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root) { -#ifdef _WIN32 - const char *const roots[] = {"C:\\cbm-root-%PATH%", "C:\\cbm-root-!name!", - "C:\\cbm-root-^name"}; - const char *project = "windows-cmd-root-validation"; - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - ASSERT_NOT_NULL(srv); - cbm_store_t *store = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(store); - cbm_mcp_server_set_project(srv, project); - mcp_command_hook_probe_t command_probe = {0}; - cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe); + char source_path[CBM_SZ_4K]; + (void)snprintf(source_path, sizeof(source_path), "%s/should_not_index.py", repo_dir); + FILE *source = cbm_fopen(source_path, "wb"); + ASSERT_NOT_NULL(source); + ASSERT_TRUE(fputs("def should_not_index():\n return True\n", source) >= 0); + ASSERT_EQ(fclose(source), 0); - for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { - ASSERT_EQ(cbm_store_upsert_project(store, project, roots[i]), CBM_STORE_OK); - char *response = cbm_mcp_handle_tool( - srv, "detect_changes", - "{\"project\":\"windows-cmd-root-validation\",\"base_branch\":\"main\"}"); - ASSERT_NOT_NULL(response); - ASSERT_NOT_NULL(strstr(response, "project path contains invalid characters")); - free(response); - } - ASSERT_EQ(command_probe.diff_calls, 0); - ASSERT_EQ(command_probe.merge_base_calls, 0); - cbm_mcp_server_free(srv); - PASS(); -#else - SKIP_PLATFORM("cmd.exe interpolation validation runs on Windows"); -#endif -} + char *project = cbm_project_name_from_path(repo_dir); + ASSERT_NOT_NULL(project); + char db_path[CBM_SZ_4K]; + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache_dir, project); -/* Opt-in workspace boundary: when CBM_ALLOWED_ROOT is set, index_repository - * must refuse a repo_path that resolves outside it. Unset (the default) imposes - * no restriction. */ -TEST(index_repository_honors_allowed_root) { - char allowed[512]; - snprintf(allowed, sizeof(allowed), "%s/cbm_allowed_XXXXXX", cbm_tmpdir()); - if (!cbm_mkdtemp(allowed)) { - FAIL("cbm_mkdtemp failed"); - } - cbm_setenv("CBM_ALLOWED_ROOT", allowed, 1); + char self_path[CBM_SZ_4K] = {0}; + ASSERT_TRUE(idxfailclosed_self_path(self_path)); + char *const child_argv[] = { + self_path, "__cbm_mcp_idxfailclosed_probe", repo_dir, cache_dir, NULL, + }; + (void)fflush(NULL); + pid_t child = -1; + ASSERT_EQ(posix_spawn(&child, self_path, NULL, NULL, child_argv, environ), 0); + ASSERT_TRUE(child > 0); + int status = 0; + ASSERT_EQ(waitpid(child, &status, 0), child); + bool exited = WIFEXITED(status); + int child_result = exited ? WEXITSTATUS(status) : -1; + bool database_absent = !cbm_file_exists(db_path); - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char args[1024]; - snprintf(args, sizeof(args), - "{\"jsonrpc\":\"2.0\",\"id\":88,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_repository\"," - "\"arguments\":{\"repo_path\":\"%s/../..\"}}}", - allowed); /* resolves to a parent, outside the allowed root */ - char *resp = cbm_mcp_server_handle(srv, args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "outside the allowed root")); - free(resp); + cleanup_project_db(cache_dir, project); + free(project); + (void)cbm_unlink(source_path); + (void)th_rmtree(repo_dir); + (void)th_rmtree(cache_dir); - cbm_unsetenv("CBM_ALLOWED_ROOT"); - cbm_mcp_server_free(srv); - cbm_rmdir(allowed); + ASSERT_TRUE(exited); + ASSERT_EQ(child_result, IDXFAILCLOSED_OK); + ASSERT_TRUE(database_absent); PASS(); +#endif } TEST(index_repository_relative_path_uses_explicit_session_root) { @@ -10037,122 +18365,6 @@ TEST(index_repository_relative_path_uses_explicit_session_root) { PASS(); } -/* A daemon-backed session validates repo_path against its own session root, but - * the supervised worker is a fresh process that inherits the daemon's cwd. A - * relative path must therefore be resolved once by the session and forwarded to - * the worker as that same canonical absolute path. The decoy repo makes an - * unsanitized handoff observable: forwarding the original "repo" indexes the - * cwd-relative decoy instead of the validated session repo. */ -enum { - IDXCANON_OK = 0, - IDXCANON_GETCWD_FAILED = 71, - IDXCANON_CHDIR_FAILED = 72, - IDXCANON_NO_SERVER = 73, - IDXCANON_CONTEXT_FAILED = 74, - IDXCANON_NO_SPAWN = 75, - IDXCANON_NO_RESULT = 76, - IDXCANON_NOT_INDEXED = 77, - IDXCANON_WRONG_PROJECT = 78, - IDXCANON_DECOY_INDEXED = 79, - IDXCANON_TARGET_MISSING = 80, - IDXCANON_CWD_RESTORE_FAILED = 81, -}; - -#ifndef _WIN32 -static int idxcanon_supervised_session_path_check(const char *session_root, const char *decoy_cwd) { - char saved_cwd[CBM_SZ_4K]; - if (!cbm_getcwd(saved_cwd, sizeof(saved_cwd))) { - return IDXCANON_GETCWD_FAILED; - } - if (cbm_chdir(decoy_cwd) != 0) { - return IDXCANON_CHDIR_FAILED; - } - - /* Match a real supervisor host. Environment changes are isolated to this - * forked child and inherited by its worker; the parent test process keeps - * its supervisor kill switch and allowed-root environment untouched. */ - cbm_index_supervisor_mark_host(); - cbm_unsetenv("CBM_INDEX_SUPERVISOR"); - cbm_unsetenv("CBM_ALLOWED_ROOT"); - cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); - cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); - - char session_repo[CBM_SZ_4K]; - char decoy_repo[CBM_SZ_4K]; - snprintf(session_repo, sizeof(session_repo), "%s/repo", session_root); - snprintf(decoy_repo, sizeof(decoy_repo), "%s/repo", decoy_cwd); - char *session_project = cbm_project_name_from_path(session_repo); - char *decoy_project = cbm_project_name_from_path(decoy_repo); - - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - int code = IDXCANON_OK; - if (!srv) { - code = IDXCANON_NO_SERVER; - } else if (!cbm_mcp_server_set_session_context(srv, session_root, session_root)) { - code = IDXCANON_CONTEXT_FAILED; - } - - int spawns_before = cbm_index_supervisor_spawn_count(); - char *resp = code == IDXCANON_OK - ? cbm_mcp_handle_tool(srv, "index_repository", - "{\"repo_path\":\"repo\",\"mode\":\"fast\"}") - : NULL; - int spawns_after = cbm_index_supervisor_spawn_count(); - if (code == IDXCANON_OK && spawns_after == spawns_before) { - code = IDXCANON_NO_SPAWN; - } else if (code == IDXCANON_OK && !resp) { - code = IDXCANON_NO_RESULT; - } else if (code == IDXCANON_OK && - !response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { - code = IDXCANON_NOT_INDEXED; - } - - if (code == IDXCANON_OK) { - char expected[CBM_SZ_4K]; - snprintf(expected, sizeof(expected), "\"project\":\"%s\"", - session_project ? session_project : ""); - if (!session_project || !response_contains_json_fragment(resp, expected)) { - code = IDXCANON_WRONG_PROJECT; - } - } - free(resp); - - /* A raw "repo" handoff is interpreted relative to decoy_cwd by the worker - * and creates this project DB. Its absence proves the original JSON did not - * substitute a different path after the parent validated session_repo. */ - if (code == IDXCANON_OK) { - const char *cache = getenv("CBM_CACHE_DIR"); - char decoy_db[CBM_SZ_4K]; - snprintf(decoy_db, sizeof(decoy_db), "%s/%s.db", cache ? cache : "", - decoy_project ? decoy_project : ""); - if (!cache || !decoy_project || cbm_file_size(decoy_db) >= 0) { - code = IDXCANON_DECOY_INDEXED; - } - } - - if (code == IDXCANON_OK) { - char query[CBM_SZ_4K]; - snprintf(query, sizeof(query), - "{\"project\":\"%s\",\"name_pattern\":\"canonical_target_fn\"," - "\"label\":\"Function\"}", - session_project ? session_project : ""); - char *search = cbm_mcp_handle_tool(srv, "search_graph", query); - if (!session_project || !search || !strstr(search, "canonical_target_fn")) { - code = IDXCANON_TARGET_MISSING; - } - free(search); - } - - cbm_mcp_server_free(srv); - free(session_project); - free(decoy_project); - if (cbm_chdir(saved_cwd) != 0 && code == IDXCANON_OK) { - code = IDXCANON_CWD_RESTORE_FAILED; - } - return code; -} -#endif - TEST(index_repository_supervisor_uses_canonical_session_path) { #ifdef _WIN32 SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)"); @@ -10229,15 +18441,10 @@ TEST(index_repository_supervisor_uses_canonical_session_path) { #endif } -/* ══════════════════════════════════════════════════════════════════ - * SUITE - * ══════════════════════════════════════════════════════════════════ */ - SUITE(mcp) { RUN_TEST(mcp_path_within_root_rejects_escape); - RUN_TEST(detect_changes_rejects_option_like_base_branch); - RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_base_branch); - RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root); + RUN_TEST(detect_changes_rejects_option_like_base_branch_before_git); + RUN_TEST(detect_changes_handles_cmd_metacharacters_as_literal_argv); RUN_TEST(index_repository_honors_allowed_root); /* JSON-RPC parsing */ RUN_TEST(jsonrpc_parse_request); @@ -10252,6 +18459,7 @@ SUITE(mcp) { RUN_TEST(jsonrpc_parse_empty_string); RUN_TEST(jsonrpc_parse_missing_jsonrpc_field); RUN_TEST(jsonrpc_parse_missing_method); + RUN_TEST(jsonrpc_parse_rejects_wrong_version); RUN_TEST(jsonrpc_parse_string_id); RUN_TEST(jsonrpc_parse_no_params); RUN_TEST(jsonrpc_parse_extra_whitespace); @@ -10263,9 +18471,14 @@ SUITE(mcp) { /* MCP protocol helpers */ RUN_TEST(mcp_initialize_response); + RUN_TEST(mcp_initialize_resources_do_not_claim_static_list_changes); RUN_TEST(mcp_tools_list); + RUN_TEST(mcp_tools_list_classic_mode); RUN_TEST(mcp_tools_help_list_matches_registry); RUN_TEST(mcp_tools_list_latest_metadata); + RUN_TEST(mcp_tool_input_schemas_are_closed_in_classic_and_streamlined_modes); + RUN_TEST(mcp_canonical_input_schemas_cover_implemented_format_and_verbose_options); + RUN_TEST(mcp_index_repository_auto_dep_limit_schema_uses_shared_bounds); RUN_TEST(mcp_tools_have_behavior_annotations); RUN_TEST(mcp_index_repository_declares_name_override_issue571); RUN_TEST(mcp_tools_array_schemas_have_items); @@ -10275,6 +18488,7 @@ SUITE(mcp) { RUN_TEST(mcp_text_result_wraps_plain_text_as_structured_content); RUN_TEST(mcp_cancel_matches_request_id); RUN_TEST(mcp_text_result_error); + RUN_TEST(supervised_index_response_publication_status_contract); /* Argument extraction */ RUN_TEST(mcp_get_tool_name); @@ -10300,6 +18514,7 @@ SUITE(mcp) { /* Server protocol handling */ RUN_TEST(server_handle_initialize); + RUN_TEST(server_handle_initialize_names_classic_source_tool); RUN_TEST(server_handle_initialized_notification); RUN_TEST(server_handle_tools_list); RUN_TEST(server_handle_tools_list_defaults_to_all_tools_and_accepts_cursor); @@ -10316,48 +18531,130 @@ SUITE(mcp) { /* Server handle — edge cases */ RUN_TEST(server_handle_invalid_json); RUN_TEST(server_handle_empty_object); + RUN_TEST(server_handle_invalid_request_preserves_valid_id); + RUN_TEST(resource_error_preserves_string_id); RUN_TEST(server_handle_tools_call_missing_name); + RUN_TEST(server_handle_tools_call_rejects_non_object_arguments); + RUN_TEST(server_handle_unknown_tool_preserves_string_id); + RUN_TEST(first_graph_call_reports_retryable_startup_index_without_consuming_ready_context); + RUN_TEST(first_graph_call_is_ready_or_retryable_until_startup_index_publishes); + RUN_TEST(first_search_code_call_is_ready_or_retryable_until_startup_index_publishes); + RUN_TEST(first_search_reports_automatic_index_block_reason); /* Tool handlers */ RUN_TEST(tool_list_projects_empty); + RUN_TEST(tool_list_projects_includes_tmp_prefixed_project); +#ifdef _WIN32 + RUN_TEST(tool_list_and_query_projects_in_cjk_cache_path_windows); +#endif + RUN_TEST(tool_list_projects_first_context_resolves_session_store); + RUN_TEST(tool_index_repository_first_context_uses_published_target_project); + RUN_TEST(tool_index_repository_unpublished_result_keeps_session_context); + RUN_TEST(response_context_disabled_does_not_consume_first_delivery); + RUN_TEST(tool_list_projects_paginates_with_explicit_full_compatibility); + RUN_TEST(resolve_store_quarantines_structurally_corrupt_db); + RUN_TEST(resolve_store_leaves_foreign_sqlite_db_untouched); RUN_TEST(tool_get_graph_schema_empty); + RUN_TEST(tool_get_graph_schema_uses_ready_overlay_schema); + RUN_TEST(first_response_context_uses_ready_overlay_schema); + RUN_TEST(tool_cross_repo_mode_honors_name_override); RUN_TEST(tool_unknown_tool); + RUN_TEST(tool_unknown_argument_is_actionable_execution_error); + RUN_TEST(tool_search_code_legacy_search_in_is_bounded_and_actionable); + RUN_TEST(tool_query_graph_legacy_cypher_alias_remains_bounded); RUN_TEST(tool_search_graph_basic); RUN_TEST(tool_trace_totals_respect_test_filter); RUN_TEST(tool_get_architecture_cycles_detects_scc); RUN_TEST(tool_get_code_snippet_clips_whole_file_node); RUN_TEST(tool_search_graph_includes_node_properties); - RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields); + RUN_TEST(tool_search_graph_warns_on_stale_pagerank_view); + RUN_TEST(tool_search_graph_warns_on_stale_route_view); + RUN_TEST(tool_search_graph_reports_dirty_metadata_without_hiding_canonical_rows); + RUN_TEST(tool_search_graph_uses_overlay_active_node_rows); + RUN_TEST(tool_get_code_clean_path_skips_overlay_summary_and_warns_when_dirty); + RUN_TEST(tool_get_code_uses_overlay_active_symbol_span); + RUN_TEST(tool_search_graph_uses_overlay_active_relationship_rows); + RUN_TEST(tool_search_graph_uses_overlay_active_inbound_relationship_rows); + RUN_TEST(tool_search_graph_query_reports_dirty_metadata_without_hiding_results); + RUN_TEST(tool_search_graph_query_sees_file_delta_fts_updates); + RUN_TEST(tool_search_graph_query_uses_overlay_active_rows); + RUN_TEST(tool_search_graph_query_uses_additive_overlay_without_tombstone); + RUN_TEST(tool_search_graph_overlay_tokenless_query_uses_graph_filters); + RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); + RUN_TEST(tool_search_graph_query_uses_search_limit_config); + RUN_TEST(tool_search_graph_query_rejects_bad_semantic_query); + RUN_TEST(tool_search_graph_semantic_query_rejects_non_string_array_items); + RUN_TEST(tool_search_graph_semantic_query_without_vector_tables_is_empty_not_error); + RUN_TEST(tool_search_graph_semantic_query_keyword_allocation_failure_is_atomic); + RUN_TEST(tool_search_graph_semantic_query_propagates_keyword_33_store_error); + RUN_TEST(tool_search_graph_semantic_query_propagates_store_error_in_toon); + RUN_TEST(tool_search_graph_semantic_query_does_not_mask_store_error_with_graph_json); + RUN_TEST(tool_search_graph_semantic_query_does_not_mask_store_error_with_bm25); + RUN_TEST(tool_search_graph_semantic_query_warns_on_stale_semantic_view); + RUN_TEST(tool_search_graph_semantic_only_json_does_not_return_unfiltered_nodes); + RUN_TEST(tool_search_graph_blocks_internal_fields_and_compacts_json_properties); RUN_TEST(tool_lean_defaults_schema_and_status); RUN_TEST(tool_output_regression_gate); RUN_TEST(tool_output_byte_budgets); - RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); - RUN_TEST(mcp_resource_discovery_methods_return_empty_lists); + RUN_TEST(mcp_discovery_methods_return_supported_lists); RUN_TEST(tool_query_graph_basic); + RUN_TEST(tool_query_graph_chained_with_optional_multi_order_formats); + RUN_TEST(tool_query_graph_uses_query_max_rows_config_when_omitted); + RUN_TEST(tool_query_graph_fails_loudly_when_working_row_budget_is_exhausted); + RUN_TEST(tool_query_graph_warns_on_stale_route_view); + RUN_TEST(tool_query_graph_reports_dirty_metadata_as_canonical_only); + RUN_TEST(tool_query_graph_uses_ready_overlay_for_node_only_query); + RUN_TEST(tool_query_graph_uses_additive_overlay_without_tombstone); + RUN_TEST(tool_query_graph_uses_active_relationship_query_with_ready_overlay); + RUN_TEST(tool_query_graph_uses_active_variable_length_relationship_query_with_ready_overlay); + RUN_TEST(tool_query_graph_uses_active_edges_for_degree_and_exists); + RUN_TEST(tool_query_graph_keeps_id_query_canonical_with_ready_overlay); + RUN_TEST(tool_query_graph_warns_when_broad_query_returns_stale_route); + RUN_TEST(tool_query_graph_warns_on_stale_semantic_edges); + RUN_TEST(tool_query_graph_warns_on_stale_similarity_edges); RUN_TEST(tool_index_status_no_project); + RUN_TEST(status_surfaces_share_exact_graph_stats); RUN_TEST(tool_check_index_coverage_finds_path_beyond_status_cap); RUN_TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges); + RUN_TEST(first_response_and_status_resource_share_coverage_generation_state); RUN_TEST(tool_check_index_coverage_preserves_multiple_scope_labels); RUN_TEST(tool_check_index_coverage_rejects_stale_generation); RUN_TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed); RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors); RUN_TEST(tool_index_status_includes_git_metadata); + RUN_TEST(tool_index_status_distinguishes_dirty_worktree_from_head); + RUN_TEST(tool_index_status_reports_dirty_metadata); + RUN_TEST(tool_index_status_reports_overlay_read_view_counts); /* Tool handlers with validation */ - RUN_TEST(tool_trace_call_path_not_found); + RUN_TEST(tool_trace_path_not_found); + RUN_TEST(tool_trace_call_path_alias_dispatches); RUN_TEST(tool_trace_missing_function_name); - RUN_TEST(tool_trace_call_path_ambiguous); + RUN_TEST(tool_trace_path_ambiguous); + RUN_TEST(tool_trace_path_prefers_definition); + RUN_TEST(tool_trace_path_warns_on_stale_rank_views); + RUN_TEST(tool_trace_path_reports_dirty_metadata_as_canonical_only); RUN_TEST(tool_trace_union_records_min_hop_across_seeds); RUN_TEST(tool_trace_pagination_exactly_once); - RUN_TEST(tool_trace_call_path_prefers_definition); RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed); RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped); - RUN_TEST(tool_trace_call_path_depth_clamped); - RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned); - RUN_TEST(tool_trace_call_path_dts_stub_unions_with_impl); + RUN_TEST(tool_trace_path_evidence_uses_shortest_path_predecessor); RUN_TEST(tool_delete_project_not_found); RUN_TEST(tool_get_architecture_empty); RUN_TEST(tool_get_architecture_emits_populated_sections); + RUN_TEST(tool_get_architecture_reports_cluster_budget_omission); + RUN_TEST(tool_get_architecture_warns_on_stale_derived_views); + RUN_TEST(tool_get_architecture_reports_dirty_metadata_as_canonical_only); + RUN_TEST(tool_get_architecture_uses_overlay_active_entry_points); + RUN_TEST(tool_get_architecture_uses_overlay_active_routes); + RUN_TEST(tool_get_architecture_uses_overlay_active_file_summaries); + RUN_TEST(resource_architecture_uses_ready_overlay_summaries); + RUN_TEST(resources_report_stale_architecture_and_omit_rank_values); + RUN_TEST(resource_schema_uses_ready_overlay_counts); + RUN_TEST(resource_arch_rel_patterns_use_ready_overlay); + RUN_TEST(tool_trace_call_path_depth_clamped); + RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned); + RUN_TEST(tool_trace_call_path_dts_stub_unions_with_impl); RUN_TEST(tool_get_architecture_overview_compact_subset_pr560); RUN_TEST(tool_get_architecture_rejects_unknown_aspect_pr560); RUN_TEST(tool_get_architecture_accepts_project_name_alias_issue640); @@ -10368,11 +18665,23 @@ SUITE(mcp) { /* Pipeline-dependent tool handlers */ RUN_TEST(tool_index_repository_missing_path); + RUN_TEST(tool_index_repository_auto_index_deps_arg_disables_deps); + RUN_TEST(tool_index_repository_exact_moderate_preserves_semantic_stale_state); + RUN_TEST(tool_index_repository_auto_dep_limit_arg_caps_deps); + RUN_TEST(tool_index_repository_reports_dependency_file_limit_skip); + RUN_TEST(tool_index_repository_after_publish_starts_overlay_compaction_worker); + RUN_TEST(tool_index_repository_reports_incremental_containment_reason); RUN_TEST(tool_get_code_snippet_missing_qn); RUN_TEST(tool_get_code_snippet_not_found); RUN_TEST(tool_search_code_missing_pattern); RUN_TEST(tool_search_code_no_project); RUN_TEST(search_code_multi_word); + RUN_TEST(search_code_preserves_valid_utf8_source); + RUN_TEST(search_code_reports_resolved_project_for_empty_json_and_toon_results); + RUN_TEST(search_code_reports_dirty_graph_metadata_without_hiding_live_matches); + RUN_TEST(search_code_uses_overlay_active_nodes_for_graph_annotations); + RUN_TEST(search_code_limit_zero_uses_config_default); + RUN_TEST(search_code_files_mode_names_each_summary_count_unit); RUN_TEST(search_code_scoped_path_with_spaces_issue687); #ifdef _WIN32 RUN_TEST(search_code_scoped_path_with_cjk_root_issue903); @@ -10382,6 +18691,9 @@ SUITE(mcp) { RUN_TEST(search_code_invalid_regex_errors_issue283); RUN_TEST(search_code_literal_pipe_warns_issue282); RUN_TEST(search_code_ampersand_accepted_issue272); + RUN_TEST(search_code_exact_path_filter_scopes_traversal); + RUN_TEST(search_code_git_worktree_scope_includes_untracked_source); + RUN_TEST(search_code_file_pattern_uses_indexed_scope_when_available); RUN_TEST(tool_detect_changes_no_project); RUN_TEST(tool_manage_adr_no_project); RUN_TEST(tool_manage_adr_get_with_existing_adr); @@ -10391,14 +18703,15 @@ SUITE(mcp) { RUN_TEST(tool_index_repository_resolves_root_path_from_project_name_issue1211); RUN_TEST(tool_index_repository_unknown_project_name_still_requires_repo_path); RUN_TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr); - RUN_TEST(index_repository_relative_path_uses_explicit_session_root); - RUN_TEST(index_repository_supervisor_uses_canonical_session_path); RUN_TEST(index_repository_cli_name_override_issue823); - RUN_TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery); RUN_TEST(index_supervisor_gate_requires_marked_host_issue845); - RUN_TEST(index_supervisor_start_failure_is_fail_closed_in_real_host); RUN_TEST(index_bg_paths_route_through_supervisor_issue832); RUN_TEST(sequential_service_edge_props_are_valid_json_issue898); + RUN_TEST(file_backed_store_is_released_at_request_end_not_pinned); + RUN_TEST(resolve_store_validates_and_serves_with_one_query_open); + RUN_TEST(request_store_release_collection_can_be_isolated_for_measurement); + RUN_TEST(request_store_retention_can_be_isolated_for_measurement); + RUN_TEST(index_repository_rejects_unknown_mode_instead_of_silent_full); RUN_TEST(index_second_inprocess_run_survives_issue773); RUN_TEST(index_recovery_parallel_quarantines_crasher); RUN_TEST(tool_manage_adr_not_found_rich_error); @@ -10411,12 +18724,17 @@ SUITE(mcp) { RUN_TEST(detect_changes_zero_overlap_falls_back_issue1363); RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty); + RUN_TEST(mcp_overlay_compaction_worker_uses_own_store_and_joins); + RUN_TEST(mcp_overlay_compaction_worker_reaps_finished_before_next_start); + RUN_TEST(mcp_overlay_compaction_worker_missing_db_does_not_create_store); + RUN_TEST(mcp_overlay_compaction_worker_rejects_invalid_inputs); + RUN_TEST(mcp_overlay_compaction_worker_free_joins_pending_worker); - /* Query store generation freshness */ - RUN_TEST(query_store_reopens_after_database_replacement); /* Query store read-only (data integrity) */ RUN_TEST(readonly_query_does_not_mutate_db); RUN_TEST(readonly_query_succeeds_on_readonly_fs); + RUN_TEST(watcher_publication_reopens_cached_store_generation); + RUN_TEST(external_process_publication_reopens_cached_store_generation); /* Idle store eviction */ RUN_TEST(store_idle_eviction); @@ -10436,14 +18754,32 @@ SUITE(mcp) { RUN_TEST(parse_file_uri_spaces_in_path); RUN_TEST(parse_file_uri_null_out_path); RUN_TEST(parse_file_uri_zero_size); + RUN_TEST(mcp_incremental_artifact_failure_reports_published_graph); /* Poll/getline FILE* buffering fix */ #ifndef _WIN32 RUN_TEST(mcp_server_run_rapid_messages); + RUN_TEST(mcp_stdio_output_has_only_jsonrpc_messages); + RUN_TEST(mcp_hidden_tools_reveal_sends_list_changed); + RUN_TEST(mcp_codex_static_catalog_needs_no_reveal_notification); + RUN_TEST(mcp_hidden_tools_reveal_frames_list_changed); + RUN_TEST(mcp_notify_index_published_sends_list_changed_once); + RUN_TEST(mcp_published_schema_refreshes_description_once); + RUN_TEST(mcp_notify_before_any_tools_list_suppressed); + RUN_TEST(mcp_delete_project_sends_list_changed); + RUN_TEST(mcp_delete_project_noop_sends_no_list_changed); + RUN_TEST(mcp_index_repository_inprocess_sends_list_changed); + RUN_TEST(mcp_autoindex_thread_sends_list_changed); + RUN_TEST(mcp_index_dependencies_sends_list_changed); + RUN_TEST(mcp_overlay_compaction_sends_list_changed); #endif /* Snippet resolution (port of snippet_test.go) */ RUN_TEST(snippet_exact_qn); + RUN_TEST(snippet_signature_mode_retains_property_metadata); + RUN_TEST(snippet_source_key_is_code_body_only); + RUN_TEST(snippet_invalid_mode_errors); + RUN_TEST(snippet_compact_false_name_present); RUN_TEST(snippet_qn_suffix); RUN_TEST(snippet_unique_short_name); RUN_TEST(snippet_name_tier); @@ -10466,10 +18802,22 @@ SUITE(mcp) { RUN_TEST(mcp_auto_watch_default_registers_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853); + /* upstream-main-only tests */ + RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields); + RUN_TEST(tool_trace_call_path_not_found); + RUN_TEST(tool_trace_call_path_ambiguous); + RUN_TEST(tool_trace_call_path_prefers_definition); + RUN_TEST(query_store_reopens_after_database_replacement); + RUN_TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery); + RUN_TEST(index_supervisor_start_failure_is_fail_closed_in_real_host); + RUN_TEST(index_repository_relative_path_uses_explicit_session_root); + RUN_TEST(index_repository_supervisor_uses_canonical_session_path); } -/* Kept separate so daemon-coordination regressions can be iterated without - * running the much larger MCP behavior suite. */ +/* Split out of SUITE(mcp) so `mcp_mutation_guard` can be selected on its own: + Makefile.cbm TEST_TSAN_SUITES names it explicitly, because the mutation gate, + the request-scoped cancellation paths, and the corrupt-store cleanup guard are + threaded production surfaces that must run under ThreadSanitizer. */ SUITE(mcp_mutation_guard) { RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases); RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); @@ -10477,20 +18825,23 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_manage_adr_read_paths_skip_blocking_mutation_guard); RUN_TEST(tool_manage_adr_read_missing_store_skips_mutation_guard); RUN_TEST(tool_manage_adr_legacy_migration_tries_without_blocking); + RUN_TEST(tool_manage_adr_corrupt_store_busy_is_retryable); + RUN_TEST(tool_manage_adr_corrupt_store_missing_try_guard_reports_configuration); RUN_TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean); RUN_TEST(tool_outer_request_scope_preserves_predispatch_cancel); RUN_TEST(tool_index_repository_early_raw_cancel_survives_index_entry); + RUN_TEST(tool_index_repository_lock_wait_honors_request_cancel); RUN_TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds); RUN_TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order); RUN_TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets); RUN_TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases); RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases); RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting); + RUN_TEST(tool_cross_repo_missing_target_is_skipped_and_counted_not_failed); RUN_TEST(tool_cross_repo_honors_source_name_override); + RUN_TEST(tool_cosmetic_root_path_store_is_retained_not_quarantined); RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested); RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal); - RUN_TEST(tool_manage_adr_corrupt_store_busy_is_retryable); - RUN_TEST(tool_manage_adr_corrupt_store_missing_try_guard_reports_configuration); RUN_TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait); RUN_TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name); RUN_TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal); diff --git a/tests/test_node_creation_probe.c b/tests/test_node_creation_probe.c index d3475c4af..51a6a856e 100644 --- a/tests/test_node_creation_probe.c +++ b/tests/test_node_creation_probe.c @@ -76,7 +76,9 @@ static cbm_store_t *ncp_open_indexed(NcpLangProj *lp) { if (!home) home = "/tmp"; char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); + /* Honor CBM_CACHE_DIR so this matches the pipeline write path (test isolation). */ + snprintf(cache_dir, sizeof(cache_dir), "%s", + cbm_resolve_cache_dir() ? cbm_resolve_cache_dir() : "/tmp"); cbm_mkdir(cache_dir); snprintf(lp->dbpath, sizeof(lp->dbpath), "%s/%s.db", cache_dir, lp->project); unlink(lp->dbpath); diff --git a/tests/test_pagerank.c b/tests/test_pagerank.c new file mode 100644 index 000000000..916376a82 --- /dev/null +++ b/tests/test_pagerank.c @@ -0,0 +1,1784 @@ +/* + * test_pagerank.c — Tests for PageRank (node) + LinkRank (edge) ranking. + * + * TDD: All tests written BEFORE implementation. They should fail (RED) + * until the corresponding feature is implemented (GREEN). + * + * References: + * - igraph test suite: pagerank, multigraph, dangling, complete graph + * - NetworkX test suite: test_pagerank, test_dangling, test_empty + * - aider repomap: edge weights, file rank distribution + * - Kim et al. (2010) LinkRank: edge ranking formula + */ +#include "../src/foundation/compat.h" +#include "../src/foundation/constants.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include /* cbm_config_open/set/get_double for with_config tuning */ +#include +#include +#include +#include +#include +#include +#include + +/* ── Test helpers ──────────────────────────────────────────── */ + +static int64_t add_node(cbm_store_t *s, const char *project, const char *name) { + cbm_node_t n = {0}; + n.project = project; + n.label = "Function"; + n.name = name; + n.qualified_name = name; + n.file_path = "test.c"; + return cbm_store_upsert_node(s, &n); +} + +static int64_t add_edge(cbm_store_t *s, const char *project, + int64_t src, int64_t dst, const char *type) { + cbm_edge_t e = {0}; + e.project = project; + e.source_id = src; + e.target_id = dst; + e.type = type; + return cbm_store_insert_edge(s, &e); +} + +static double get_pr(cbm_store_t *s, int64_t node_id) { + return cbm_pagerank_get(s, node_id); +} + +static int count_table_rows(cbm_store_t *s, const char *table) { + sqlite3 *db = cbm_store_get_db(s); + if (!db) return -1; + char sql[CBM_LINE_BUF]; + snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM %s", table); + sqlite3_stmt *stmt = NULL; + int count = 0; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) count = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + } + return count; +} + +static int get_project_for_row(cbm_store_t *s, const char *table, + const char *id_column, int64_t id, + char *buf, size_t buf_sz) { + sqlite3 *db = cbm_store_get_db(s); + if (!db || !buf || buf_sz == 0) return 0; + buf[0] = '\0'; + char sql[CBM_LINE_BUF]; + snprintf(sql, sizeof(sql), "SELECT project FROM %s WHERE %s = ?1", + table, id_column); + sqlite3_stmt *stmt = NULL; + int found = 0; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, id); + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *project = (const char *)sqlite3_column_text(stmt, 0); + snprintf(buf, buf_sz, "%s", project ? project : ""); + found = 1; + } + sqlite3_finalize(stmt); + } + return found; +} + +static double get_lr_by_edge_id(cbm_store_t *s, int64_t edge_id) { + return cbm_linkrank_get(s, edge_id); +} + +static double get_linkrank_in_by_node_id(cbm_store_t *s, int64_t node_id) { + sqlite3 *db = cbm_store_get_db(s); + if (!db) return 0.0; + sqlite3_stmt *stmt = NULL; + double value = 0.0; + if (sqlite3_prepare_v2(db, + "SELECT COALESCE(linkrank_in, 0.0) " + "FROM node_degree WHERE node_id = ?1", + -1, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_int64(stmt, 1, node_id); + if (sqlite3_step(stmt) == SQLITE_ROW) { + value = sqlite3_column_double(stmt, 0); + } + sqlite3_finalize(stmt); + } + return value; +} + +/* ── 1. Core PageRank tests ──────────────────────────────── */ + +TEST(pagerank_empty_graph) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + int rc = cbm_pagerank_compute_default(s, "empty"); + ASSERT_EQ(rc, 0); /* 0 nodes ranked */ + ASSERT_EQ(count_table_rows(s, "pagerank"), 0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_single_node) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "single", "/tmp/single"); + int64_t a = add_node(s, "single", "main"); + int rc = cbm_pagerank_compute_default(s, "single"); + ASSERT_EQ(rc, 1); + double r = get_pr(s, a); + ASSERT_TRUE(fabs(r - 1.0) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_two_nodes_one_edge) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "two", "/tmp/two"); + int64_t a = add_node(s, "two", "caller"); + int64_t b = add_node(s, "two", "callee"); + add_edge(s, "two", a, b, "CALLS"); + cbm_pagerank_compute_default(s, "two"); + double ra = get_pr(s, a); + double rb = get_pr(s, b); + ASSERT_TRUE(rb > ra); /* callee gets more rank */ + ASSERT_TRUE(fabs(ra + rb - 1.0) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_cycle) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "cyc", "/tmp/cyc"); + int64_t a = add_node(s, "cyc", "funcA"); + int64_t b = add_node(s, "cyc", "funcB"); + add_edge(s, "cyc", a, b, "CALLS"); + add_edge(s, "cyc", b, a, "CALLS"); + cbm_pagerank_compute_default(s, "cyc"); + double ra = get_pr(s, a); + double rb = get_pr(s, b); + ASSERT_TRUE(fabs(ra - rb) < 0.01); /* symmetric */ + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_star_topology) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "star", "/tmp/star"); + int64_t hub = add_node(s, "star", "hub"); + int64_t s1 = add_node(s, "star", "spoke1"); + int64_t s2 = add_node(s, "star", "spoke2"); + int64_t s3 = add_node(s, "star", "spoke3"); + add_edge(s, "star", s1, hub, "CALLS"); + add_edge(s, "star", s2, hub, "CALLS"); + add_edge(s, "star", s3, hub, "CALLS"); + cbm_pagerank_compute_default(s, "star"); + ASSERT_TRUE(get_pr(s, hub) > get_pr(s, s1)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_edge_weights) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "wt", "/tmp/wt"); + int64_t a = add_node(s, "wt", "source"); + int64_t b = add_node(s, "wt", "called"); + int64_t c = add_node(s, "wt", "used"); + add_edge(s, "wt", a, b, "CALLS"); /* weight 1.0 */ + add_edge(s, "wt", a, c, "USAGE"); /* weight 0.2 */ + cbm_pagerank_compute_default(s, "wt"); + ASSERT_TRUE(get_pr(s, b) > get_pr(s, c)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_convergence) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "chain", "/tmp/chain"); + int64_t ids[5]; + for (int i = 0; i < 5; i++) { + char name[8]; snprintf(name, sizeof(name), "n%d", i); + ids[i] = add_node(s, "chain", name); + } + for (int i = 0; i < 4; i++) add_edge(s, "chain", ids[i], ids[i+1], "CALLS"); + int rc = cbm_pagerank_compute_default(s, "chain"); + ASSERT_EQ(rc, 5); + ASSERT_TRUE(get_pr(s, ids[4]) > get_pr(s, ids[0])); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_sum_to_one) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "sum", "/tmp/sum"); + int64_t a = add_node(s, "sum", "a"); + int64_t b = add_node(s, "sum", "b"); + int64_t c = add_node(s, "sum", "c"); + add_edge(s, "sum", a, b, "CALLS"); + add_edge(s, "sum", b, c, "CALLS"); + add_edge(s, "sum", c, a, "CALLS"); + cbm_pagerank_compute_default(s, "sum"); + double total = get_pr(s, a) + get_pr(s, b) + get_pr(s, c); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_stored_in_db) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "db", "/tmp/db"); + add_node(s, "db", "f1"); + add_node(s, "db", "f2"); + cbm_pagerank_compute_default(s, "db"); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + cbm_derived_view_state_t state = {0}; + ASSERT_EQ(cbm_store_get_derived_view_state(s, "db", CBM_STORE_DERIVED_VIEW_PAGERANK, + &state), + CBM_STORE_OK); + ASSERT_STR_EQ(state.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + ASSERT_EQ(state.source_generation, CBM_STORE_DERIVED_GENERATION_UNKNOWN); + cbm_store_derived_view_state_free_fields(&state); + + ASSERT_EQ(cbm_store_get_derived_view_state(s, "db", CBM_STORE_DERIVED_VIEW_LINKRANK, + &state), + CBM_STORE_OK); + ASSERT_STR_EQ(state.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&state); + + ASSERT_EQ(cbm_store_get_derived_view_state(s, "db", CBM_STORE_DERIVED_VIEW_NODE_DEGREE, + &state), + CBM_STORE_OK); + ASSERT_STR_EQ(state.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&state); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_views_complete_requires_all_rank_views) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "fresh", "/tmp/fresh"); + add_node(s, "fresh", "f1"); + add_node(s, "fresh", "f2"); + + ASSERT_FALSE(cbm_pagerank_views_complete(s, "fresh")); + cbm_pagerank_compute_default(s, "fresh"); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "fresh")); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "fresh", CBM_STORE_DERIVED_VIEW_LINKRANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "fresh")); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_if_needed_repairs_missing_views) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_missing", "/tmp/refresh_missing"); + int64_t a = add_node(s, "refresh_missing", "a"); + int64_t b = add_node(s, "refresh_missing", "b"); + add_edge(s, "refresh_missing", a, b, "CALLS"); + + ASSERT_FALSE(cbm_pagerank_views_complete(s, "refresh_missing")); + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_missing", NULL, false, 0, false), 2); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "refresh_missing")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_if_needed_skips_complete_unchanged_graph) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_skip", "/tmp/refresh_skip"); + int64_t a = add_node(s, "refresh_skip", "a"); + int64_t b = add_node(s, "refresh_skip", "b"); + add_edge(s, "refresh_skip", a, b, "CALLS"); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_skip"), 2); + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_skip", NULL, false, 0, false), 0); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_if_needed_recomputes_changed_graph) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_changed", "/tmp/refresh_changed"); + int64_t a = add_node(s, "refresh_changed", "a"); + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_changed"), 1); + int64_t b = add_node(s, "refresh_changed", "b"); + add_edge(s, "refresh_changed", a, b, "CALLS"); + + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_changed", NULL, true, 0, false), 2); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_if_needed_recomputes_reindexed_deps) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_deps", "/tmp/refresh_deps"); + int64_t app = add_node(s, "refresh_deps", "app"); + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_deps"), 1); + + cbm_store_upsert_project(s, "refresh_deps.dep.lib", "/tmp/refresh_dep_lib"); + int64_t dep = add_node(s, "refresh_deps.dep.lib", "dep"); + add_edge(s, "refresh_deps", app, dep, "CALLS"); + + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_deps", NULL, false, 1, false), 2); + ASSERT_TRUE(get_pr(s, dep) > 0.0); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_disabled_config_clears_rank_views) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "rank_disabled", "/tmp/rank_disabled"); + int64_t a = add_node(s, "rank_disabled", "a"); + int64_t b = add_node(s, "rank_disabled", "b"); + add_edge(s, "rank_disabled", a, b, "CALLS"); + ASSERT_EQ(cbm_pagerank_compute_default(s, "rank_disabled"), 2); + ASSERT_TRUE(count_table_rows(s, "pagerank") > 0); + ASSERT_TRUE(count_table_rows(s, "linkrank") > 0); + ASSERT_TRUE(count_table_rows(s, "node_degree") > 0); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-disabled-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_ENABLED, "false"), 0); + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "rank_disabled", cfg, true, 0, false), 0); + ASSERT_EQ(count_table_rows(s, "pagerank"), 0); + ASSERT_EQ(count_table_rows(s, "linkrank"), 0); + ASSERT_EQ(count_table_rows(s, "node_degree"), 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "rank_disabled")); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_finalizes_exact_graph_stats) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "rank_stats", "/tmp/rank_stats"), CBM_STORE_OK); + int64_t a = add_node(s, "rank_stats", "a"); + int64_t b = add_node(s, "rank_stats", "b"); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(add_edge(s, "rank_stats", a, b, "CALLS"), 0); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "rank_stats"), 2); + cbm_project_graph_stats_t stats = {0}; + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "rank_stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 2); + ASSERT_EQ(stats.edge_count, 1); + ASSERT_EQ(stats.ranked_node_count, 2); + ASSERT_NOT_NULL(stats.pagerank_computed_at); + cbm_store_project_graph_stats_free_fields(&stats); + + int64_t c = add_node(s, "rank_stats", "c"); + ASSERT_GT(c, 0); + ASSERT_GT(add_edge(s, "rank_stats", b, c, "CALLS"), 0); + ASSERT_EQ(cbm_store_mark_rank_derived_views_stale( + s, "rank_stats", CBM_STORE_DERIVED_GENERATION_UNKNOWN), + CBM_STORE_OK); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-summary-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, CBM_RANK_REFRESH_DEFER_EXACT_DELTA_REINDEXES), + 0); + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "rank_stats", cfg, true, 0, true), 0); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "rank_stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 3); + ASSERT_EQ(stats.edge_count, 2); + ASSERT_EQ(stats.ranked_node_count, 2); + cbm_store_project_graph_stats_free_fields(&stats); + + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_ENABLED, "false"), 0); + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "rank_stats", cfg, true, 0, false), 0); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "rank_stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 3); + ASSERT_EQ(stats.edge_count, 2); + ASSERT_EQ(stats.ranked_node_count, 0); + ASSERT_NULL(stats.pagerank_computed_at); + cbm_store_project_graph_stats_free_fields(&stats); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + + ASSERT_EQ(cbm_store_upsert_project(s, "rank_empty", "/tmp/rank_empty"), CBM_STORE_OK); + ASSERT_EQ(cbm_pagerank_compute_default(s, "rank_empty"), 0); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "rank_empty", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 0); + ASSERT_EQ(stats.edge_count, 0); + ASSERT_EQ(stats.ranked_node_count, 0); + cbm_store_project_graph_stats_free_fields(&stats); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_defer_exact_delta_reindexes_defers_only_with_stale_rank_views) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_policy", "/tmp/refresh_policy"); + int64_t a = add_node(s, "refresh_policy", "a"); + int64_t b = add_node(s, "refresh_policy", "b"); + add_edge(s, "refresh_policy", a, b, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-refresh-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, CBM_RANK_REFRESH_DEFER_EXACT_DELTA_REINDEXES), + 0); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_policy"), 2); + int64_t c = add_node(s, "refresh_policy", "c"); + add_edge(s, "refresh_policy", b, c, "CALLS"); + const char *rank_views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_VIEW_LINKRANK, + CBM_STORE_DERIVED_VIEW_NODE_DEGREE}; + int rank_view_count = (int)(sizeof(rank_views) / sizeof(rank_views[0])); + ASSERT_EQ(cbm_store_mark_derived_views_stale(s, "refresh_policy", + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + rank_views, rank_view_count), + CBM_STORE_OK); + + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_policy", cfg, true, 0, true), 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "refresh_policy")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_policy", cfg, true, 0, false), 3); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "refresh_policy")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 3); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_defer_exact_delta_reindexes_does_not_defer_containment) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_exact_only", "/tmp/refresh_exact_only"); + int64_t a = add_node(s, "refresh_exact_only", "a"); + int64_t b = add_node(s, "refresh_exact_only", "b"); + add_edge(s, "refresh_exact_only", a, b, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-refresh-exact-only-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, CBM_RANK_REFRESH_DEFER_EXACT_DELTA_REINDEXES), + 0); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_exact_only"), 2); + int64_t c = add_node(s, "refresh_exact_only", "c"); + add_edge(s, "refresh_exact_only", b, c, "CALLS"); + ASSERT_EQ(cbm_store_mark_rank_derived_views_stale( + s, "refresh_exact_only", CBM_STORE_DERIVED_GENERATION_UNKNOWN), + CBM_STORE_OK); + + ASSERT_EQ(cbm_pagerank_refresh_after_publish( + s, "refresh_exact_only", cfg, true, 0, + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_CONTAINMENT), + 3); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "refresh_exact_only")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 3); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_defer_all_incremental_reindexes_defers_containment) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_incremental", "/tmp/refresh_incremental"); + int64_t a = add_node(s, "refresh_incremental", "a"); + int64_t b = add_node(s, "refresh_incremental", "b"); + add_edge(s, "refresh_incremental", a, b, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-refresh-incremental-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, + CBM_RANK_REFRESH_DEFER_ALL_INCREMENTAL_REINDEXES), + 0); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_incremental"), 2); + int64_t c = add_node(s, "refresh_incremental", "c"); + add_edge(s, "refresh_incremental", b, c, "CALLS"); + ASSERT_EQ(cbm_store_mark_rank_derived_views_stale( + s, "refresh_incremental", CBM_STORE_DERIVED_GENERATION_UNKNOWN), + CBM_STORE_OK); + + ASSERT_EQ(cbm_pagerank_refresh_after_publish( + s, "refresh_incremental", cfg, true, 0, + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_CONTAINMENT), + 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "refresh_incremental")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_defer_all_incremental_reindexes_defers_full_fallback) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_fallback", "/tmp/refresh_fallback"); + int64_t a = add_node(s, "refresh_fallback", "a"); + int64_t b = add_node(s, "refresh_fallback", "b"); + add_edge(s, "refresh_fallback", a, b, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-refresh-fallback-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + + ASSERT_EQ(cbm_rank_refresh_publish_from_pipeline(CBM_PIPELINE_PUBLISH_FULL, false), + CBM_RANK_REFRESH_PUBLISH_FULL); + ASSERT_EQ(cbm_rank_refresh_publish_from_pipeline(CBM_PIPELINE_PUBLISH_FULL, true), + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_FALLBACK); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_fallback"), 2); + int64_t c = add_node(s, "refresh_fallback", "c"); + add_edge(s, "refresh_fallback", b, c, "CALLS"); + ASSERT_EQ(cbm_store_mark_rank_derived_views_stale( + s, "refresh_fallback", CBM_STORE_DERIVED_GENERATION_UNKNOWN), + CBM_STORE_OK); + + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, CBM_RANK_REFRESH_DEFER_EXACT_DELTA_REINDEXES), + 0); + ASSERT_EQ(cbm_pagerank_refresh_after_publish( + s, "refresh_fallback", cfg, true, 0, + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_FALLBACK), + 3); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "refresh_fallback")); + + int64_t d = add_node(s, "refresh_fallback", "d"); + add_edge(s, "refresh_fallback", c, d, "CALLS"); + ASSERT_EQ(cbm_store_mark_rank_derived_views_stale( + s, "refresh_fallback", CBM_STORE_DERIVED_GENERATION_UNKNOWN), + CBM_STORE_OK); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, + CBM_RANK_REFRESH_DEFER_ALL_INCREMENTAL_REINDEXES), + 0); + ASSERT_EQ(cbm_pagerank_refresh_after_publish( + s, "refresh_fallback", cfg, true, 0, + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_FALLBACK), + 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "refresh_fallback")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 3); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_default_defers_incremental_when_rank_views_stale) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_default", "/tmp/refresh_default"); + int64_t a = add_node(s, "refresh_default", "a"); + int64_t b = add_node(s, "refresh_default", "b"); + add_edge(s, "refresh_default", a, b, "CALLS"); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "refresh_default"), 2); + int64_t c = add_node(s, "refresh_default", "c"); + add_edge(s, "refresh_default", b, c, "CALLS"); + ASSERT_EQ(cbm_store_mark_rank_derived_views_stale( + s, "refresh_default", CBM_STORE_DERIVED_GENERATION_UNKNOWN), + CBM_STORE_OK); + + ASSERT_EQ(cbm_pagerank_refresh_after_publish( + s, "refresh_default", NULL, true, 0, + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_CONTAINMENT), + 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "refresh_default")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + + ASSERT_EQ(cbm_pagerank_refresh_after_publish( + s, "refresh_default", NULL, true, 1, + CBM_RANK_REFRESH_PUBLISH_INCREMENTAL_CONTAINMENT), + 3); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "refresh_default")); + ASSERT_EQ(count_table_rows(s, "pagerank"), 3); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_refresh_invalid_policy_falls_back_to_at_publish) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "refresh_invalid_policy", "/tmp/refresh_invalid_policy"); + int64_t a = add_node(s, "refresh_invalid_policy", "a"); + int64_t b = add_node(s, "refresh_invalid_policy", "b"); + add_edge(s, "refresh_invalid_policy", a, b, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-refresh-bad-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_REFRESH, "bogus"), -1); + ASSERT_EQ(th_set_raw_config_value(tmpdir, CBM_CONFIG_RANK_REFRESH, "bogus"), 0); + + const char *rank_views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_VIEW_LINKRANK, + CBM_STORE_DERIVED_VIEW_NODE_DEGREE}; + int rank_view_count = (int)(sizeof(rank_views) / sizeof(rank_views[0])); + ASSERT_EQ(cbm_store_mark_derived_views_stale(s, "refresh_invalid_policy", + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + rank_views, rank_view_count), + CBM_STORE_OK); + ASSERT_EQ(cbm_pagerank_refresh_if_needed(s, "refresh_invalid_policy", cfg, true, 0, true), 2); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "refresh_invalid_policy")); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_recompute_replaces) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "re", "/tmp/re"); + int64_t a = add_node(s, "re", "f1"); + cbm_pagerank_compute_default(s, "re"); + double r1 = get_pr(s, a); + cbm_pagerank_compute_default(s, "re"); + ASSERT_EQ(count_table_rows(s, "pagerank"), 1); + double r2 = get_pr(s, a); + ASSERT_TRUE(fabs(r1 - r2) < 0.001); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_unconverged_iteration_budget_does_not_publish) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "unconverged", "/tmp/unconverged"), CBM_STORE_OK); + int64_t a = add_node(s, "unconverged", "a"); + int64_t b = add_node(s, "unconverged", "b"); + int64_t c = add_node(s, "unconverged", "c"); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(c, 0); + ASSERT_GT(add_edge(s, "unconverged", a, b, "CALLS"), 0); + ASSERT_GT(add_edge(s, "unconverged", b, c, "CALLS"), 0); + + /* A one-iteration numerical budget is not a semantic answer. With an + * effectively exact positive tolerance this graph cannot converge in one + * step, so no rank view may be published as complete. */ + ASSERT_EQ(cbm_pagerank_compute(s, "unconverged", CBM_PAGERANK_DAMPING, DBL_MIN, 1, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL), + CBM_STORE_ERR); + ASSERT_EQ(count_table_rows(s, "pagerank"), 0); + ASSERT_EQ(count_table_rows(s, "linkrank"), 0); + ASSERT_EQ(count_table_rows(s, "node_degree"), 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "unconverged")); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_publication_failure_rolls_back_all_rank_views) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "atomic", "/tmp/atomic"), CBM_STORE_OK); + int64_t a = add_node(s, "atomic", "a"); + int64_t b = add_node(s, "atomic", "b"); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(add_edge(s, "atomic", a, b, "CALLS"), 0); + ASSERT_EQ(cbm_pagerank_compute_default(s, "atomic"), 2); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "atomic")); + + int pagerank_rows = count_table_rows(s, "pagerank"); + int linkrank_rows = count_table_rows(s, "linkrank"); + int degree_rows = count_table_rows(s, "node_degree"); + double old_rank = get_pr(s, b); + ASSERT_TRUE(old_rank > 0.0); + + ASSERT_EQ(cbm_store_exec(s, + "CREATE TRIGGER fail_linkrank_publish " + "BEFORE INSERT ON linkrank BEGIN " + "SELECT RAISE(FAIL, 'injected linkrank publication failure'); END;"), + CBM_STORE_OK); + ASSERT_EQ(cbm_pagerank_compute_default(s, "atomic"), CBM_STORE_ERR); + + /* The three rank tables and their complete metadata are one published + * generation. A failure in the second table must retain the entire prior + * generation rather than mixing new PageRank/degree with empty LinkRank. */ + ASSERT_EQ(count_table_rows(s, "pagerank"), pagerank_rows); + ASSERT_EQ(count_table_rows(s, "linkrank"), linkrank_rows); + ASSERT_EQ(count_table_rows(s, "node_degree"), degree_rows); + ASSERT_FLOAT_EQ(get_pr(s, b), old_rank, CBM_PAGERANK_EPSILON); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "atomic")); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_scan_failure_preserves_prior_rank_generation) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "scan_fail", "/tmp/scan_fail"), CBM_STORE_OK); + int64_t a = add_node(s, "scan_fail", "a"); + int64_t b = add_node(s, "scan_fail", "b"); + int64_t c = add_node(s, "scan_fail", "c"); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(c, 0); + ASSERT_GT(add_edge(s, "scan_fail", a, b, "CALLS"), 0); + ASSERT_GT(add_edge(s, "scan_fail", b, c, "CALLS"), 0); + ASSERT_EQ(cbm_pagerank_compute_default(s, "scan_fail"), 3); + + int pagerank_rows = count_table_rows(s, "pagerank"); + int linkrank_rows = count_table_rows(s, "linkrank"); + int degree_rows = count_table_rows(s, "node_degree"); + double old_rank = get_pr(s, c); + ASSERT_TRUE(old_rank > 0.0); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "scan_fail")); + + const cbm_pagerank_test_scan_t scans[] = { + CBM_PAGERANK_TEST_SCAN_NODES, + CBM_PAGERANK_TEST_SCAN_EDGES, + }; + for (size_t i = 0; i < sizeof(scans) / sizeof(scans[0]); i++) { + cbm_pagerank_test_fail_scan_after(scans[i], 1); + ASSERT_EQ(cbm_pagerank_compute_default(s, "scan_fail"), CBM_STORE_ERR); + ASSERT_EQ(count_table_rows(s, "pagerank"), pagerank_rows); + ASSERT_EQ(count_table_rows(s, "linkrank"), linkrank_rows); + ASSERT_EQ(count_table_rows(s, "node_degree"), degree_rows); + ASSERT_FLOAT_EQ(get_pr(s, c), old_rank, CBM_PAGERANK_EPSILON); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "scan_fail")); + } + + cbm_store_close(s); + + /* A failpoint armed for an empty target scan must expire at SQLITE_DONE. + * Add an edge only after that successful computation so any leaked + * thread-local state becomes an observable failure on the next scan. */ + s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "scan_done", "/tmp/scan_done"), CBM_STORE_OK); + a = add_node(s, "scan_done", "a"); + ASSERT_GT(a, 0); + cbm_pagerank_test_fail_scan_after(CBM_PAGERANK_TEST_SCAN_EDGES, 0); + ASSERT_EQ(cbm_pagerank_compute_default(s, "scan_done"), 1); + ASSERT_GT(add_edge(s, "scan_done", a, a, "CALLS"), 0); + ASSERT_EQ(cbm_pagerank_compute_default(s, "scan_done"), 1); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_publication_respects_outer_transaction_rollback) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "outer_txn", "/tmp/outer_txn"), CBM_STORE_OK); + int64_t a = add_node(s, "outer_txn", "a"); + int64_t b = add_node(s, "outer_txn", "b"); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(add_edge(s, "outer_txn", a, b, "CALLS"), 0); + + ASSERT_EQ(cbm_store_begin(s), CBM_STORE_OK); + ASSERT_EQ(cbm_pagerank_compute_default(s, "outer_txn"), 2); + ASSERT_EQ(count_table_rows(s, "pagerank"), 2); + ASSERT_EQ(count_table_rows(s, "linkrank"), 1); + ASSERT_EQ(count_table_rows(s, "node_degree"), 2); + ASSERT_TRUE(cbm_pagerank_views_complete(s, "outer_txn")); + ASSERT_EQ(cbm_store_rollback(s), CBM_STORE_OK); + + /* The publication savepoint must not commit its caller's transaction. + * Rolling back that outer transaction removes all three O(N + E) rank + * views and their completeness metadata as one logical generation. */ + ASSERT_EQ(count_table_rows(s, "pagerank"), 0); + ASSERT_EQ(count_table_rows(s, "linkrank"), 0); + ASSERT_EQ(count_table_rows(s, "node_degree"), 0); + ASSERT_FALSE(cbm_pagerank_views_complete(s, "outer_txn")); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_full_scope_includes_deps) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj", "/tmp/proj"); + cbm_store_upsert_project(s, "proj.dep.lib", "/tmp/lib"); + int64_t a = add_node(s, "proj", "app_main"); + int64_t b = add_node(s, "proj.dep.lib", "lib_func"); + add_edge(s, "proj", a, b, "CALLS"); + int rc = cbm_pagerank_compute(s, "proj", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + ASSERT_EQ(rc, 2); + ASSERT_TRUE(get_pr(s, b) > 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_full_scope_preserves_dep_project_attribution) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj_attr", "/tmp/proj_attr"); + cbm_store_upsert_project(s, "proj_attr.dep.lib", "/tmp/lib_attr"); + int64_t app = add_node(s, "proj_attr", "app_main"); + int64_t dep_a = add_node(s, "proj_attr.dep.lib", "lib_a"); + int64_t dep_b = add_node(s, "proj_attr.dep.lib", "lib_b"); + add_edge(s, "proj_attr", app, dep_a, "CALLS"); + int64_t dep_edge = add_edge(s, "proj_attr.dep.lib", dep_a, dep_b, "CALLS"); + + int rc = cbm_pagerank_compute(s, "proj_attr", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + ASSERT_EQ(rc, 3); + + char project_buf[CBM_PATH_MAX]; + ASSERT_TRUE(get_project_for_row(s, "pagerank", "node_id", dep_b, + project_buf, sizeof(project_buf))); + ASSERT_TRUE(strcmp(project_buf, "proj_attr.dep.lib") == 0); + ASSERT_TRUE(get_project_for_row(s, "node_degree", "node_id", dep_b, + project_buf, sizeof(project_buf))); + ASSERT_TRUE(strcmp(project_buf, "proj_attr.dep.lib") == 0); + ASSERT_TRUE(get_project_for_row(s, "linkrank", "edge_id", dep_edge, + project_buf, sizeof(project_buf))); + ASSERT_TRUE(strcmp(project_buf, "proj_attr.dep.lib") == 0); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_project_scope_excludes_deps) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj2", "/tmp/proj2"); + cbm_store_upsert_project(s, "proj2.dep.lib", "/tmp/lib2"); + add_node(s, "proj2", "my_func"); + int64_t dep = add_node(s, "proj2.dep.lib", "lib_func"); + int rc = cbm_pagerank_compute(s, "proj2", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_PROJECT); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(get_pr(s, dep) == 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_rank_scope_config_controls_scope_and_clears_stale_rows) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "cfgscope", "/tmp/cfgscope"); + cbm_store_upsert_project(s, "cfgscope.dep.lib", "/tmp/lib"); + int64_t app = add_node(s, "cfgscope", "app_main"); + int64_t dep = add_node(s, "cfgscope.dep.lib", "lib_func"); + add_edge(s, "cfgscope", app, dep, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-scope-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_SCOPE, "full"), 0); + ASSERT_EQ(cbm_pagerank_compute_with_config(s, "cfgscope", cfg), 2); + ASSERT_TRUE(get_pr(s, dep) > 0.0); + + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_SCOPE, "project"), 0); + ASSERT_EQ(cbm_pagerank_compute_with_config(s, "cfgscope", cfg), 1); + ASSERT_TRUE(get_pr(s, app) > 0.0); + ASSERT_TRUE(get_pr(s, dep) == 0.0); + + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_RANK_SCOPE, "invalid"), -1); + ASSERT_EQ(th_set_raw_config_value(tmpdir, CBM_CONFIG_RANK_SCOPE, "invalid"), 0); + ASSERT_EQ(cbm_pagerank_compute_with_config(s, "cfgscope", cfg), 2); + ASSERT_TRUE(get_pr(s, dep) > 0.0); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_dangling_nodes) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "dang", "/tmp/dang"); + int64_t a = add_node(s, "dang", "caller"); + int64_t b = add_node(s, "dang", "leaf"); + add_edge(s, "dang", a, b, "CALLS"); + cbm_pagerank_compute_default(s, "dang"); + ASSERT_TRUE(get_pr(s, b) > 0.0); + double total = get_pr(s, a) + get_pr(s, b); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_null_safety) { + ASSERT_EQ(cbm_pagerank_compute_default(NULL, "x"), -1); + ASSERT_EQ(cbm_pagerank_compute_default(NULL, NULL), -1); + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_EQ(cbm_pagerank_compute_default(s, NULL), -1); + ASSERT_EQ(cbm_pagerank_compute_default(s, ""), -1); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_id_map_capacity_rejects_unrepresentable_node_counts) { + int capacity = 0; + ASSERT_TRUE(cbm_pagerank_test_id_map_capacity(1, &capacity)); + ASSERT_EQ(capacity, CBM_HASHMAP_LOAD_FACTOR + SKIP_ONE); + + const int max_nodes = (INT_MAX - SKIP_ONE) / CBM_HASHMAP_LOAD_FACTOR; + ASSERT_TRUE(cbm_pagerank_test_id_map_capacity(max_nodes, &capacity)); + ASSERT_EQ(capacity, max_nodes * CBM_HASHMAP_LOAD_FACTOR + SKIP_ONE); + ASSERT_FALSE(cbm_pagerank_test_id_map_capacity(max_nodes + SKIP_ONE, &capacity)); + ASSERT_FALSE(cbm_pagerank_test_id_map_capacity(0, &capacity)); + ASSERT_FALSE(cbm_pagerank_test_id_map_capacity(-1, &capacity)); + ASSERT_FALSE(cbm_pagerank_test_id_map_capacity(1, NULL)); + PASS(); +} + +/* ── 2. Edge cases from igraph/NetworkX ──────────────────── */ + +TEST(pagerank_self_loop) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "self", "/tmp/self"); + int64_t a = add_node(s, "self", "recursive"); + add_edge(s, "self", a, a, "CALLS"); + int rc = cbm_pagerank_compute_default(s, "self"); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(fabs(get_pr(s, a) - 1.0) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_disconnected_components) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "disc", "/tmp/disc"); + int64_t a = add_node(s, "disc", "a"); + int64_t b = add_node(s, "disc", "b"); + int64_t c = add_node(s, "disc", "c"); + int64_t d = add_node(s, "disc", "d"); + add_edge(s, "disc", a, b, "CALLS"); + add_edge(s, "disc", c, d, "CALLS"); + cbm_pagerank_compute_default(s, "disc"); + double total = get_pr(s, a) + get_pr(s, b) + get_pr(s, c) + get_pr(s, d); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + double comp1 = get_pr(s, a) + get_pr(s, b); + double comp2 = get_pr(s, c) + get_pr(s, d); + ASSERT_TRUE(fabs(comp1 - comp2) < 0.15); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_all_dangling_no_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "noedge", "/tmp/noedge"); + int64_t ids[5]; + for (int i = 0; i < 5; i++) { + char name[16]; snprintf(name, sizeof(name), "n%d", i); + ids[i] = add_node(s, "noedge", name); + } + int rc = cbm_pagerank_compute_default(s, "noedge"); + ASSERT_EQ(rc, 5); + double expected = 1.0 / 5.0; + for (int i = 0; i < 5; i++) + ASSERT_TRUE(fabs(get_pr(s, ids[i]) - expected) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_complete_graph) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "kn", "/tmp/kn"); + int64_t ids[4]; + for (int i = 0; i < 4; i++) { + char name[8]; snprintf(name, sizeof(name), "k%d", i); + ids[i] = add_node(s, "kn", name); + } + for (int i = 0; i < 4; i++) + for (int j = 0; j < 4; j++) + if (i != j) add_edge(s, "kn", ids[i], ids[j], "CALLS"); + cbm_pagerank_compute_default(s, "kn"); + for (int i = 0; i < 4; i++) + ASSERT_TRUE(fabs(get_pr(s, ids[i]) - 0.25) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_multigraph_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "multi", "/tmp/multi"); + int64_t a = add_node(s, "multi", "caller"); + int64_t b = add_node(s, "multi", "callee"); + add_edge(s, "multi", a, b, "CALLS"); + add_edge(s, "multi", a, b, "IMPORTS"); + cbm_pagerank_compute_default(s, "multi"); + ASSERT_TRUE(get_pr(s, b) > get_pr(s, a)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_large_graph_stability) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "big", "/tmp/big"); + int64_t ids[100]; + for (int i = 0; i < 100; i++) { + char name[16]; snprintf(name, sizeof(name), "f%d", i); + ids[i] = add_node(s, "big", name); + } + for (int i = 0; i < 99; i++) + add_edge(s, "big", ids[i], ids[i+1], "CALLS"); + int rc = cbm_pagerank_compute_default(s, "big"); + ASSERT_EQ(rc, 100); + double total = 0.0; + for (int i = 0; i < 100; i++) total += get_pr(s, ids[i]); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + ASSERT_TRUE(get_pr(s, ids[99]) > get_pr(s, ids[0])); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_zero_weight_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "zw", "/tmp/zw"); + int64_t a = add_node(s, "zw", "a"); + int64_t b = add_node(s, "zw", "b"); + add_edge(s, "zw", a, b, "CONFIGURES"); + cbm_edge_weights_t zero_w = CBM_DEFAULT_EDGE_WEIGHTS; + zero_w.configures = 0.0; + cbm_pagerank_compute(s, "zw", CBM_PAGERANK_DAMPING, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, &zero_w, CBM_RANK_SCOPE_FULL); + ASSERT_TRUE(fabs(get_pr(s, a) - get_pr(s, b)) < 0.01); + cbm_store_close(s); + PASS(); +} + +/* The canonical mapping must connect every exact edge type to the same struct + * field used by defaults, config loading, and validation. Each case enables + * only its mapped field, making a mapping drift observable as a dangling edge. + * The generated case table is compile-time-only; each tiny graph retains the + * algorithm's O(I * (V + E)) runtime and O(V + E) memory. */ +TEST(pagerank_edge_weight_field_map_is_complete) { + static const struct { + const char *edge_type; + size_t field_offset; + const char *name; + } cases[] = { +#define CBM_PAGERANK_MAPPING_CASE(edge_type, default_token, config_token, field) \ + {edge_type, offsetof(cbm_edge_weights_t, field), #config_token}, + CBM_PAGERANK_EDGE_WEIGHT_FIELDS(CBM_PAGERANK_MAPPING_CASE) +#undef CBM_PAGERANK_MAPPING_CASE + {"FUTURE_EDGE_KIND", offsetof(cbm_edge_weights_t, default_weight), "unknown-fallback"}, + }; + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char project[CBM_SZ_128]; + int written = snprintf(project, sizeof(project), "weight-map-%s", cases[i].name); + ASSERT_TRUE(written > 0 && (size_t)written < sizeof(project)); + cbm_store_upsert_project(s, project, "/tmp/weight-map"); + int64_t source = add_node(s, project, "source"); + int64_t target = add_node(s, project, "target"); + add_edge(s, project, source, target, cases[i].edge_type); + + cbm_edge_weights_t weights = {0}; + double *mapped_weight = (double *)((unsigned char *)&weights + cases[i].field_offset); + *mapped_weight = 1.0; + ASSERT_EQ(cbm_pagerank_compute(s, project, CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, &weights, + CBM_RANK_SCOPE_PROJECT), + 2); + ASSERT_TRUE(get_pr(s, target) > get_pr(s, source)); + } + cbm_store_close(s); + PASS(); +} + +/* Direct API callers bypass config-set validation. A non-finite weight must + * therefore fall back to that field's named default instead of poisoning the + * weighted out-degree and either publishing non-finite ranks or exhausting + * the O(max_iter * (V + E)) convergence budget. */ +TEST(pagerank_invalid_custom_weight_falls_back_to_field_default) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "invalid-weight", "/tmp/invalid-weight"); + int64_t source = add_node(s, "invalid-weight", "source"); + int64_t calls_target = add_node(s, "invalid-weight", "calls-target"); + int64_t imports_target = add_node(s, "invalid-weight", "imports-target"); + add_edge(s, "invalid-weight", source, calls_target, "CALLS"); + add_edge(s, "invalid-weight", source, imports_target, "IMPORTS"); + add_edge(s, "invalid-weight", calls_target, source, "CALLS"); + + ASSERT_EQ(cbm_pagerank_compute_default(s, "invalid-weight"), 3); + double expected_source = get_pr(s, source); + double expected_calls = get_pr(s, calls_target); + double expected_imports = get_pr(s, imports_target); + + cbm_edge_weights_t weights = CBM_DEFAULT_EDGE_WEIGHTS; + weights.calls = NAN; + ASSERT_EQ(cbm_pagerank_compute(s, "invalid-weight", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, &weights, + CBM_DEFAULT_RANK_SCOPE), + 3); + ASSERT_TRUE(fabs(get_pr(s, source) - expected_source) < CBM_PAGERANK_EPSILON); + ASSERT_TRUE(fabs(get_pr(s, calls_target) - expected_calls) < CBM_PAGERANK_EPSILON); + ASSERT_TRUE(fabs(get_pr(s, imports_target) - expected_imports) < CBM_PAGERANK_EPSILON); + + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_custom_damping_high) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "hi_d", "/tmp/hi_d"); + int64_t a = add_node(s, "hi_d", "a"); + int64_t b = add_node(s, "hi_d", "b"); + add_edge(s, "hi_d", a, b, "CALLS"); + cbm_pagerank_compute(s, "hi_d", 0.99, CBM_PAGERANK_EPSILON, + 50, &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + double total = get_pr(s, a) + get_pr(s, b); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + ASSERT_TRUE(get_pr(s, b) > get_pr(s, a)); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_custom_damping_low) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lo_d", "/tmp/lo_d"); + int64_t a = add_node(s, "lo_d", "a"); + int64_t b = add_node(s, "lo_d", "b"); + add_edge(s, "lo_d", a, b, "CALLS"); + cbm_pagerank_compute(s, "lo_d", 0.1, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, &CBM_DEFAULT_EDGE_WEIGHTS, + CBM_RANK_SCOPE_FULL); + ASSERT_TRUE(fabs(get_pr(s, a) - get_pr(s, b)) < 0.1); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_max_iter_zero) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "mi0", "/tmp/mi0"); + add_node(s, "mi0", "a"); + add_node(s, "mi0", "b"); + add_edge(s, "mi0", 1, 2, "CALLS"); + /* max_iter <= 0 resets to default */ + int rc = cbm_pagerank_compute(s, "mi0", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, 0, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_FULL); + ASSERT_TRUE(rc > 0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_known_values) { + /* 3-node cycle: all should get equal rank 1/3 */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "kv", "/tmp/kv"); + int64_t a = add_node(s, "kv", "a"); + int64_t b = add_node(s, "kv", "b"); + int64_t c = add_node(s, "kv", "c"); + add_edge(s, "kv", a, b, "CALLS"); + add_edge(s, "kv", b, c, "CALLS"); + add_edge(s, "kv", c, a, "CALLS"); + cbm_pagerank_compute_default(s, "kv"); + double expected = 1.0 / 3.0; + ASSERT_TRUE(fabs(get_pr(s, a) - expected) < 0.01); + ASSERT_TRUE(fabs(get_pr(s, b) - expected) < 0.01); + ASSERT_TRUE(fabs(get_pr(s, c) - expected) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_known_values_asymmetric) { + /* NetworkX test graph: 6 nodes, node 4 highest rank */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "nx", "/tmp/nx"); + int64_t n[7]; + for (int i = 1; i <= 6; i++) { + char name[8]; snprintf(name, sizeof(name), "n%d", i); + n[i] = add_node(s, "nx", name); + } + add_edge(s, "nx", n[1], n[2], "CALLS"); + add_edge(s, "nx", n[1], n[3], "CALLS"); + add_edge(s, "nx", n[3], n[1], "CALLS"); + add_edge(s, "nx", n[3], n[2], "CALLS"); + add_edge(s, "nx", n[3], n[5], "CALLS"); + add_edge(s, "nx", n[4], n[5], "CALLS"); + add_edge(s, "nx", n[4], n[6], "CALLS"); + add_edge(s, "nx", n[5], n[4], "CALLS"); + add_edge(s, "nx", n[5], n[6], "CALLS"); + add_edge(s, "nx", n[6], n[4], "CALLS"); + cbm_pagerank_compute_default(s, "nx"); + ASSERT_TRUE(get_pr(s, n[4]) > get_pr(s, n[1])); + ASSERT_TRUE(get_pr(s, n[2]) > 0.0); /* dangling node gets rank */ + double total = 0; + for (int i = 1; i <= 6; i++) total += get_pr(s, n[i]); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_scope_deps_only) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "sd", "/tmp/sd"); + cbm_store_upsert_project(s, "sd.dep.lib", "/tmp/sdlib"); + int64_t proj_node = add_node(s, "sd", "app"); + int64_t dep_node = add_node(s, "sd.dep.lib", "lib"); + int rc = cbm_pagerank_compute(s, "sd", CBM_PAGERANK_DAMPING, + CBM_PAGERANK_EPSILON, CBM_PAGERANK_MAX_ITER, + &CBM_DEFAULT_EDGE_WEIGHTS, CBM_RANK_SCOPE_DEPS); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(get_pr(s, dep_node) > 0.0); + ASSERT_TRUE(get_pr(s, proj_node) == 0.0); + cbm_store_close(s); + PASS(); +} + +/* ── 3. LinkRank tests ───────────────────────────────────── */ + +TEST(linkrank_computed_from_pagerank) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lr", "/tmp/lr"); + add_node(s, "lr", "f1"); + add_node(s, "lr", "f2"); + add_edge(s, "lr", 1, 2, "CALLS"); + cbm_pagerank_compute_default(s, "lr"); + ASSERT_TRUE(count_table_rows(s, "linkrank") > 0); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_formula_correct) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrf", "/tmp/lrf"); + int64_t a = add_node(s, "lrf", "src"); + int64_t b = add_node(s, "lrf", "dst"); + int64_t eid = add_edge(s, "lrf", a, b, "CALLS"); + cbm_pagerank_compute_default(s, "lrf"); + double pra = get_pr(s, a); + double lr = get_lr_by_edge_id(s, eid); + /* Single outgoing CALLS (weight 1.0): LR = PR(A) * 1.0 / 1.0 = PR(A) */ + ASSERT_TRUE(fabs(lr - pra) < 0.01); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_calls_higher_than_usage) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrw", "/tmp/lrw"); + int64_t a = add_node(s, "lrw", "src"); + int64_t b = add_node(s, "lrw", "called"); + int64_t c = add_node(s, "lrw", "used"); + int64_t e1 = add_edge(s, "lrw", a, b, "CALLS"); + int64_t e2 = add_edge(s, "lrw", a, c, "USAGE"); + cbm_pagerank_compute_default(s, "lrw"); + ASSERT_TRUE(get_lr_by_edge_id(s, e1) > get_lr_by_edge_id(s, e2)); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_stored_in_db) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrs", "/tmp/lrs"); + add_node(s, "lrs", "f1"); + add_node(s, "lrs", "f2"); + add_edge(s, "lrs", 1, 2, "CALLS"); + add_edge(s, "lrs", 2, 1, "IMPORTS"); + cbm_pagerank_compute_default(s, "lrs"); + ASSERT_EQ(count_table_rows(s, "linkrank"), 2); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_self_loop_edge) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrsl", "/tmp/lrsl"); + int64_t a = add_node(s, "lrsl", "recursive"); + int64_t eid = add_edge(s, "lrsl", a, a, "CALLS"); + cbm_pagerank_compute_default(s, "lrsl"); + ASSERT_EQ(count_table_rows(s, "linkrank"), 1); + ASSERT_TRUE(get_lr_by_edge_id(s, eid) > 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_no_edges) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrne", "/tmp/lrne"); + add_node(s, "lrne", "isolated"); + cbm_pagerank_compute_default(s, "lrne"); + ASSERT_EQ(count_table_rows(s, "linkrank"), 0); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_sum_equals_pagerank_sum) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrs2", "/tmp/lrs2"); + int64_t a = add_node(s, "lrs2", "a"); + int64_t b = add_node(s, "lrs2", "b"); + int64_t c = add_node(s, "lrs2", "c"); + add_edge(s, "lrs2", a, b, "CALLS"); + add_edge(s, "lrs2", b, c, "CALLS"); + add_edge(s, "lrs2", c, a, "CALLS"); + cbm_pagerank_compute_default(s, "lrs2"); + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *st = NULL; + double lr_sum = 0.0; + sqlite3_prepare_v2(db, "SELECT SUM(rank) FROM linkrank", -1, &st, NULL); + if (sqlite3_step(st) == SQLITE_ROW) lr_sum = sqlite3_column_double(st, 0); + sqlite3_finalize(st); + double pr_sum = get_pr(s, a) + get_pr(s, b) + get_pr(s, c); + ASSERT_TRUE(fabs(lr_sum - pr_sum) < 0.05); + cbm_store_close(s); + PASS(); +} + +TEST(linkrank_in_matches_incoming_linkrank_sum) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "lrin", "/tmp/lrin"); + int64_t a = add_node(s, "lrin", "a"); + int64_t b = add_node(s, "lrin", "b"); + int64_t c = add_node(s, "lrin", "c"); + int64_t ab = add_edge(s, "lrin", a, b, "CALLS"); + int64_t cb = add_edge(s, "lrin", c, b, "USAGE"); + add_edge(s, "lrin", b, a, "CALLS"); + + cbm_pagerank_compute_default(s, "lrin"); + double incoming = get_lr_by_edge_id(s, ab) + get_lr_by_edge_id(s, cb); + ASSERT_TRUE(fabs(get_linkrank_in_by_node_id(s, b) - incoming) < 1e-9); + + cbm_store_close(s); + PASS(); +} + +/* ── 4. Integration: dep scoping ─────────────────────────── */ + +TEST(pagerank_after_dep_index) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "proj", "/tmp/proj"); + cbm_store_upsert_project(s, "proj.dep.lib", "/tmp/lib"); + int64_t a = add_node(s, "proj", "app_main"); + int64_t b = add_node(s, "proj.dep.lib", "lib_init"); + int64_t c = add_node(s, "proj.dep.lib", "lib_process"); + add_edge(s, "proj", a, b, "CALLS"); + add_edge(s, "proj.dep.lib", b, c, "CALLS"); + int rc = cbm_pagerank_compute_default(s, "proj"); + ASSERT_EQ(rc, 3); + ASSERT_TRUE(get_pr(s, c) > 0.0); + double total = get_pr(s, a) + get_pr(s, b) + get_pr(s, c); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + cbm_store_close(s); + PASS(); +} + +/* ── 5. Phase 8.5: key_functions in get_architecture ─────── */ + +TEST(architecture_key_functions_with_pagerank) { + /* After PR compute, verify key_functions array in architecture response + * with top nodes by PageRank, correct order. */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "arch", "/tmp/arch"); + int64_t ids[6]; + ids[0] = add_node(s, "arch", "hub_func"); + ids[1] = add_node(s, "arch", "spoke1"); + ids[2] = add_node(s, "arch", "spoke2"); + ids[3] = add_node(s, "arch", "spoke3"); + ids[4] = add_node(s, "arch", "spoke4"); + ids[5] = add_node(s, "arch", "leaf"); + /* hub_func called by 4 spokes → highest PageRank */ + add_edge(s, "arch", ids[1], ids[0], "CALLS"); + add_edge(s, "arch", ids[2], ids[0], "CALLS"); + add_edge(s, "arch", ids[3], ids[0], "CALLS"); + add_edge(s, "arch", ids[4], ids[0], "CALLS"); + cbm_pagerank_compute_default(s, "arch"); + /* hub_func should have highest rank */ + double hub_pr = get_pr(s, ids[0]); + double leaf_pr = get_pr(s, ids[5]); + ASSERT_TRUE(hub_pr > leaf_pr); + /* Verify key_functions query works (top N by pagerank) */ + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT n.name, pr.rank FROM nodes n " + "JOIN pagerank pr ON pr.node_id = n.id " + "WHERE n.project = 'arch' " + "ORDER BY pr.rank DESC LIMIT 3", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + /* First result should be hub_func */ + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + const char *top_name = (const char *)sqlite3_column_text(stmt, 0); + ASSERT_STR_EQ(top_name, "hub_func"); + sqlite3_finalize(stmt); + cbm_store_close(s); + PASS(); +} + +TEST(architecture_key_functions_no_pagerank) { + /* When PageRank not computed, key_functions query returns 0 rows gracefully */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "nopr", "/tmp/nopr"); + add_node(s, "nopr", "f1"); + /* Do NOT compute pagerank */ + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT n.name, pr.rank FROM nodes n " + "JOIN pagerank pr ON pr.node_id = n.id " + "WHERE n.project = 'nopr' " + "ORDER BY pr.rank DESC LIMIT 3", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + /* No rows — pagerank table empty for this project */ + ASSERT_EQ(sqlite3_step(stmt), SQLITE_DONE); + sqlite3_finalize(stmt); + cbm_store_close(s); + PASS(); +} + +/* ── 6. Phase 8.5: config-backed edge weights ────────────── */ + +TEST(pagerank_config_custom_weights) { + /* Verify the public config path, not only the direct weights struct, maps + * edge kinds into the compute core and materially changes rankings. */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "cw", "/tmp/cw"); + int64_t a = add_node(s, "cw", "source"); + int64_t b = add_node(s, "cw", "imported"); + int64_t c = add_node(s, "cw", "called"); + add_edge(s, "cw", a, b, "IMPORTS"); + add_edge(s, "cw", a, c, "CALLS"); + /* Default: CALLS=1.0, IMPORTS=0.3 → c gets more rank */ + cbm_pagerank_compute_default(s, "cw"); + double rc_default = get_pr(s, c); + double rb_default = get_pr(s, b); + ASSERT_TRUE(rc_default > rb_default); + /* Custom: boost IMPORTS to 2.0, drop CALLS to 0.1. */ + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-weight-cfg-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_EDGE_WEIGHT_IMPORTS, "2.0"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_EDGE_WEIGHT_CALLS, "0.1"), 0); + ASSERT_EQ(cbm_pagerank_compute_with_config(s, "cw", cfg), 3); + double rc_custom = get_pr(s, c); + double rb_custom = get_pr(s, b); + /* Now imported node should get more rank */ + ASSERT_TRUE(rb_custom > rc_custom); + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +/* ── 7. Phase 8.5: PageRank stats in index_status ────────── */ + +TEST(pagerank_stats_in_db) { + /* After compute, verify pagerank table has computed_at timestamp */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "stats", "/tmp/stats"); + add_node(s, "stats", "f1"); + add_node(s, "stats", "f2"); + add_edge(s, "stats", 1, 2, "CALLS"); + cbm_pagerank_compute_default(s, "stats"); + /* Verify computed_at is set */ + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *stmt = NULL; + sqlite3_prepare_v2(db, + "SELECT COUNT(*), MAX(computed_at) FROM pagerank WHERE project = 'stats'", + -1, &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int ranked = sqlite3_column_int(stmt, 0); + ASSERT_EQ(ranked, 2); + const char *ts = (const char *)sqlite3_column_text(stmt, 1); + ASSERT_NOT_NULL(ts); + ASSERT_TRUE(strlen(ts) >= 10); /* at least YYYY-MM-DD */ + sqlite3_finalize(stmt); + cbm_store_close(s); + PASS(); +} + +/* ── 8. Phase 8.5: API streamlining ──────────────────────── */ + +TEST(pagerank_conditional_degree_logic) { + /* Verify pagerank_score is populated on search results when PR is computed. + * Uses pagerank_get directly since search result integration is tested + * by the existing sort_by tests. */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "cd", "/tmp/cd"); + int64_t a = add_node(s, "cd", "func_a"); + int64_t b = add_node(s, "cd", "func_b"); + add_edge(s, "cd", a, b, "CALLS"); + /* Before PR compute: pagerank_get returns 0 */ + ASSERT_TRUE(get_pr(s, a) == 0.0); + ASSERT_TRUE(get_pr(s, b) == 0.0); + /* After PR compute: pagerank_get returns > 0 */ + cbm_pagerank_compute_default(s, "cd"); + ASSERT_TRUE(get_pr(s, a) > 0.0); + ASSERT_TRUE(get_pr(s, b) > 0.0); + cbm_store_close(s); + PASS(); +} + +TEST(pagerank_dep_source_tag_format) { + /* Verify dep source tagging uses ".dep." detection. + * cbm_is_dep_project("proj.dep.pandas", "proj") → true + * cbm_is_dep_project("proj", "proj") → false */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "dp", "/tmp/dp"); + cbm_store_upsert_project(s, "dp.dep.pandas", "/tmp/pandas"); + add_node(s, "dp", "my_func"); + add_node(s, "dp.dep.pandas", "DataFrame"); + /* Search all: both should be returned with correct source tags */ + cbm_search_params_t params = {0}; + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_TRUE(out.count >= 2); + /* Verify dep detection helper */ + ASSERT_TRUE(cbm_is_dep_project("dp.dep.pandas", "dp")); + ASSERT_FALSE(cbm_is_dep_project("dp", "dp")); + ASSERT_FALSE(cbm_is_dep_project("deputy", "dep")); + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + +/* ── 9. Phase 8.5: Edge cases ────────────────────────────── */ + +TEST(pagerank_config_weight_very_small) { + /* Very small (near-zero) edge weight should not crash. + * Ranks should still sum to ~1.0 (valid distribution). */ + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "vsm", "/tmp/vsm"); + int64_t a = add_node(s, "vsm", "a"); + int64_t b = add_node(s, "vsm", "b"); + add_edge(s, "vsm", a, b, "CALLS"); + cbm_edge_weights_t small_w = CBM_DEFAULT_EDGE_WEIGHTS; + small_w.calls = 0.001; /* near-zero weight */ + int rc = cbm_pagerank_compute(s, "vsm", CBM_PAGERANK_DAMPING, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, &small_w, CBM_RANK_SCOPE_FULL); + ASSERT_EQ(rc, 2); + /* Should not crash, ranks should sum to ~1 */ + double total = get_pr(s, a) + get_pr(s, b); + ASSERT_TRUE(fabs(total - 1.0) < 0.1); + cbm_store_close(s); + PASS(); +} + +/* #21: PageRank damping + epsilon are tunable via config keys + * (pagerank_damping, pagerank_epsilon) through cbm_pagerank_compute_with_config + * — previously only max_iter + edge weights were config-exposed; damping/epsilon + * were hard-coded #defines. This test proves the damping knob changes output. */ +TEST(pagerank_damping_epsilon_config_tunable) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "cfg", "/tmp/cfg"); + int64_t hub = add_node(s, "cfg", "hub"); + int64_t s1 = add_node(s, "cfg", "s1"); + int64_t s2 = add_node(s, "cfg", "s2"); + int64_t s3 = add_node(s, "cfg", "s3"); + add_edge(s, "cfg", hub, s1, "CALLS"); + add_edge(s, "cfg", hub, s2, "CALLS"); + add_edge(s, "cfg", hub, s3, "CALLS"); + add_edge(s, "cfg", s1, hub, "CALLS"); + + char tmpdir[CBM_PATH_MAX]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/pr-cfg-XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(tmpdir) != NULL); + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + + /* Low damping (0.5) → hub rank should differ from high damping (0.99). + * compute_with_config returns the count of ranked nodes (4) on success. */ + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_PAGERANK_DAMPING, "0.5"), 0); + ASSERT_EQ(cbm_pagerank_compute_with_config(s, "cfg", cfg), 4); + double hub_low = get_pr(s, hub); + + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_PAGERANK_DAMPING, "0.99"), 0); + ASSERT_EQ(cbm_pagerank_compute_with_config(s, "cfg", cfg), 4); + double hub_high = get_pr(s, hub); + + /* The damping knob must materially change the rank value. */ + ASSERT_TRUE(fabs(hub_low - hub_high) > 1e-6); + + /* Sanity: ranks still sum to ~1 under a custom config. */ + double total = get_pr(s, hub) + get_pr(s, s1) + get_pr(s, s2) + get_pr(s, s3); + ASSERT_TRUE(fabs(total - 1.0) < 0.05); + + cbm_config_close(cfg); + th_rmtree(tmpdir); + cbm_store_close(s); + PASS(); +} + +/* Robustness (review finding #44): a NaN damping must NOT corrupt PageRank. + * Since #21 made damping/epsilon config-tunable and cbm_config_get_double uses + * strtod (which parses "nan"), a user can `config set pagerank_damping nan`. + * The range clamp must reject NaN — IEEE-754 makes all NaN comparisons false, + * so the naive `damping < 0 || damping > 1` form lets NaN through, poisoning + * every rank and preventing convergence. */ +TEST(pagerank_nan_damping_is_clamped) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "nan", "/tmp/nan"); + int64_t a = add_node(s, "nan", "a"); + int64_t b = add_node(s, "nan", "b"); + add_edge(s, "nan", a, b, "CALLS"); + add_edge(s, "nan", b, a, "CALLS"); + + int rc = cbm_pagerank_compute(s, "nan", NAN, CBM_PAGERANK_EPSILON, + CBM_PAGERANK_MAX_ITER, NULL, CBM_DEFAULT_RANK_SCOPE); + ASSERT_EQ(rc, 2); + double ra = get_pr(s, a); + double rb = get_pr(s, b); + ASSERT_TRUE(isfinite(ra)); /* NaN damping must be clamped, not propagated */ + ASSERT_TRUE(isfinite(rb)); + ASSERT_TRUE(fabs((ra + rb) - 1.0) < 0.05); + + cbm_store_close(s); + PASS(); +} + +/* #49: out-of-range / nonsensical damping, epsilon, and max_iter must all clamp + * to safe defaults and NOT corrupt the computation or hang. Guards the + * config-tunable knobs (#21) against bad user-supplied values. */ +TEST(pagerank_invalid_inputs_clamp_cleanly) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "bad", "/tmp/bad"); + int64_t a = add_node(s, "bad", "a"); + int64_t b = add_node(s, "bad", "b"); + add_edge(s, "bad", a, b, "CALLS"); + add_edge(s, "bad", b, a, "CALLS"); + + struct { double damping; double epsilon; int max_iter; const char *label; } cases[] = { + {-0.5, CBM_PAGERANK_EPSILON, 20, "negative damping"}, /* clamp to 0.85 */ + {2.0, CBM_PAGERANK_EPSILON, 20, "damping>1"}, /* clamp to 0.85 */ + {CBM_PAGERANK_DAMPING, -1.0, 20, "negative epsilon"}, /* clamp to 1e-6 */ + {CBM_PAGERANK_DAMPING, 0.0, 20, "zero epsilon"}, /* clamp to 1e-6 */ + {CBM_PAGERANK_DAMPING, CBM_PAGERANK_EPSILON, -5, "neg max_iter"}, /* clamp to 20 */ + {CBM_PAGERANK_DAMPING, CBM_PAGERANK_EPSILON, 0, "zero max_iter"}, /* clamp to 20 */ + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + int rc = cbm_pagerank_compute(s, "bad", cases[i].damping, cases[i].epsilon, + cases[i].max_iter, NULL, CBM_DEFAULT_RANK_SCOPE); + ASSERT_EQ(rc, 2); /* both nodes ranked */ + double ra = get_pr(s, a), rb = get_pr(s, b); + ASSERT_TRUE(isfinite(ra)); + ASSERT_TRUE(isfinite(rb)); + ASSERT_TRUE(fabs((ra + rb) - 1.0) < 0.05); /* ranks still sum to ~1 */ + (void)cases[i].label; + } + cbm_store_close(s); + PASS(); +} + +/* ── Suite registration ──────────────────────────────────── */ + +SUITE(pagerank) { + /* Core PageRank (14 tests) */ + RUN_TEST(pagerank_empty_graph); + RUN_TEST(pagerank_single_node); + RUN_TEST(pagerank_two_nodes_one_edge); + RUN_TEST(pagerank_cycle); + RUN_TEST(pagerank_star_topology); + RUN_TEST(pagerank_edge_weights); + RUN_TEST(pagerank_convergence); + RUN_TEST(pagerank_damping_epsilon_config_tunable); + RUN_TEST(pagerank_nan_damping_is_clamped); + RUN_TEST(pagerank_invalid_inputs_clamp_cleanly); + RUN_TEST(pagerank_sum_to_one); + RUN_TEST(pagerank_stored_in_db); + RUN_TEST(pagerank_views_complete_requires_all_rank_views); + RUN_TEST(pagerank_refresh_if_needed_repairs_missing_views); + RUN_TEST(pagerank_refresh_if_needed_skips_complete_unchanged_graph); + RUN_TEST(pagerank_refresh_if_needed_recomputes_changed_graph); + RUN_TEST(pagerank_refresh_if_needed_recomputes_reindexed_deps); + RUN_TEST(pagerank_disabled_config_clears_rank_views); + RUN_TEST(pagerank_refresh_finalizes_exact_graph_stats); + RUN_TEST(pagerank_refresh_defer_exact_delta_reindexes_defers_only_with_stale_rank_views); + RUN_TEST(pagerank_refresh_defer_exact_delta_reindexes_does_not_defer_containment); + RUN_TEST(pagerank_refresh_defer_all_incremental_reindexes_defers_containment); + RUN_TEST(pagerank_refresh_defer_all_incremental_reindexes_defers_full_fallback); + RUN_TEST(pagerank_refresh_default_defers_incremental_when_rank_views_stale); + RUN_TEST(pagerank_refresh_invalid_policy_falls_back_to_at_publish); + RUN_TEST(pagerank_recompute_replaces); + RUN_TEST(pagerank_unconverged_iteration_budget_does_not_publish); + RUN_TEST(pagerank_publication_failure_rolls_back_all_rank_views); + RUN_TEST(pagerank_scan_failure_preserves_prior_rank_generation); + RUN_TEST(pagerank_publication_respects_outer_transaction_rollback); + RUN_TEST(pagerank_full_scope_includes_deps); + RUN_TEST(pagerank_full_scope_preserves_dep_project_attribution); + RUN_TEST(pagerank_project_scope_excludes_deps); + RUN_TEST(pagerank_rank_scope_config_controls_scope_and_clears_stale_rows); + RUN_TEST(pagerank_dangling_nodes); + RUN_TEST(pagerank_null_safety); + RUN_TEST(pagerank_id_map_capacity_rejects_unrepresentable_node_counts); + /* Edge cases from igraph/NetworkX (13 tests) */ + RUN_TEST(pagerank_self_loop); + RUN_TEST(pagerank_disconnected_components); + RUN_TEST(pagerank_all_dangling_no_edges); + RUN_TEST(pagerank_complete_graph); + RUN_TEST(pagerank_multigraph_edges); + RUN_TEST(pagerank_large_graph_stability); + RUN_TEST(pagerank_zero_weight_edges); + RUN_TEST(pagerank_edge_weight_field_map_is_complete); + RUN_TEST(pagerank_invalid_custom_weight_falls_back_to_field_default); + RUN_TEST(pagerank_custom_damping_high); + RUN_TEST(pagerank_custom_damping_low); + RUN_TEST(pagerank_max_iter_zero); + RUN_TEST(pagerank_known_values); + RUN_TEST(pagerank_known_values_asymmetric); + RUN_TEST(pagerank_scope_deps_only); + /* LinkRank (7 tests) */ + RUN_TEST(linkrank_computed_from_pagerank); + RUN_TEST(linkrank_formula_correct); + RUN_TEST(linkrank_calls_higher_than_usage); + RUN_TEST(linkrank_stored_in_db); + RUN_TEST(linkrank_self_loop_edge); + RUN_TEST(linkrank_no_edges); + RUN_TEST(linkrank_sum_equals_pagerank_sum); + RUN_TEST(linkrank_in_matches_incoming_linkrank_sum); + /* Integration (1 test) */ + RUN_TEST(pagerank_after_dep_index); + /* Phase 8.5: key_functions + config weights + stats + streamlining (7 tests) */ + RUN_TEST(architecture_key_functions_with_pagerank); + RUN_TEST(architecture_key_functions_no_pagerank); + RUN_TEST(pagerank_config_custom_weights); + RUN_TEST(pagerank_stats_in_db); + RUN_TEST(pagerank_conditional_degree_logic); + RUN_TEST(pagerank_dep_source_tag_format); + RUN_TEST(pagerank_config_weight_very_small); +} diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 4e48dc459..660584703 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -11,6 +11,7 @@ #include "test_helpers.h" #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" +#include "pipeline/lsp_resolve.h" #include "pipeline/pass_lsp_cross.h" #include "pipeline/lsp_resolve.h" #include "pipeline/worker_pool.h" @@ -19,7 +20,10 @@ #include "foundation/platform.h" #include "foundation/log.h" #include "cbm.h" +#include +#include +#include #include #include #include @@ -112,6 +116,25 @@ static void teardown_parallel_repo(void) { g_par_tmpdir[0] = '\0'; } +static void cbm_init_parallel_worker(int idx, void *ctx_ptr) { + int *rcs = (int *)ctx_ptr; + rcs[idx] = cbm_init(); +} + +TEST(parallel_cbm_init_concurrent_idempotent) { + enum { INIT_CALLS = 32, INIT_WORKERS = 4 }; + int rcs[INIT_CALLS]; + memset(rcs, 0x7f, sizeof(rcs)); + + cbm_parallel_for_opts_t opts = {.max_workers = INIT_WORKERS, .force_pthreads = false}; + cbm_parallel_for(INIT_CALLS, cbm_init_parallel_worker, rcs, opts); + + for (int i = 0; i < INIT_CALLS; i++) { + ASSERT_EQ(rcs[i], 0); + } + PASS(); +} + /* ── Run sequential pipeline on files, returning gbuf ─────────────── */ /* Free the return-type table pass_calls may have built for a harness-owned @@ -416,6 +439,70 @@ TEST(parallel_empty_files) { PASS(); } +TEST(extraction_errors_are_nonfatal_in_parallel_and_sequential_paths) { + char dir[256]; + snprintf(dir, sizeof(dir), "/tmp/cbm_extract_error_XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(dir) != NULL); + + char path[512]; + snprintf(path, sizeof(path), "%s/input.txt", dir); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fclose(f); + + cbm_file_info_t file = { + .path = path, + .rel_path = (char *)"input.txt", + .language = CBM_LANG_PYTHON, + }; + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_gbuf_t *gbuf = cbm_gbuf_new("extract-error", dir); + cbm_registry_t *registry = cbm_registry_new(); + ASSERT_NOT_NULL(gbuf); + ASSERT_NOT_NULL(registry); + cbm_pipeline_ctx_t ctx = { + .project_name = "extract-error", + .repo_path = dir, + .gbuf = gbuf, + .registry = registry, + .cancelled = &cancelled, + .pkgmap_preseeded = true, + }; + + CBMFileResult *cache[1] = {NULL}; + _Atomic int64_t shared_ids; + atomic_init(&shared_ids, 1); + ASSERT_EQ(cbm_parallel_extract(&ctx, &file, 1, cache, &shared_ids, 1), 0); + ASSERT_NOT_NULL(cache[0]); + ASSERT_FALSE(cache[0]->has_error); + int nodes_after_empty = cbm_gbuf_node_count(gbuf); + cbm_free_result(cache[0]); + cache[0] = NULL; + + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fputs("content\n", f); + fclose(f); + file.language = (CBMLanguage)-1; + /* Unsupported-language extraction is a per-file skip, not a run-level + * failure. Both paths must leave the graph unchanged and allow the caller + * to publish the successfully indexed subset. The production pipeline + * supplies ctx.pipeline and records this file in skipped[]. */ + ASSERT_EQ(cbm_parallel_extract(&ctx, &file, 1, cache, &shared_ids, 1), 0); + ASSERT_NULL(cache[0]); + ASSERT_EQ(cbm_gbuf_node_count(gbuf), nodes_after_empty); + + ASSERT_EQ(cbm_pipeline_pass_definitions(&ctx, &file, 1), 0); + ASSERT_EQ(cbm_gbuf_node_count(gbuf), nodes_after_empty); + + cbm_registry_free(registry); + cbm_gbuf_free(gbuf); + unlink(path); + rmdir(dir); + PASS(); +} + /* ── Regression: args JSON must not overflow the props buffer ──────── */ /* A call with many long string arguments makes append_args_json()'s running @@ -464,6 +551,472 @@ TEST(parallel_args_json_no_overflow) { PASS(); } +typedef struct { + int self_get_calls; +} self_get_call_ctx_t; + +static void count_self_get_call_edges(const cbm_gbuf_edge_t *edge, void *ud) { + self_get_call_ctx_t *c = ud; + if (!edge || !edge->type || strcmp(edge->type, "CALLS") != 0) { + return; + } + if (edge->source_id != edge->target_id) { + return; + } + if (edge->properties_json && strstr(edge->properties_json, "\"callee\":\"ec.get\"")) { + c->self_get_calls++; + } +} + +static int count_extracted_calls_named(const CBMFileResult *result, const char *callee_name) { + int count = 0; + if (!result || !callee_name) { + return 0; + } + for (int i = 0; i < result->calls.count; i++) { + const char *got = result->calls.items[i].callee_name; + if (got && strcmp(got, callee_name) == 0) { + count++; + } + } + return count; +} + +TEST(parallel_unresolved_route_suffix_does_not_emit_self_call) { + char dir[256]; + snprintf(dir, sizeof(dir), "/tmp/cbm_route_suffix_XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(dir) != NULL); + + const char *source = "def FilterPanel(ec, e):\n" + " return ec.get(e.type)\n"; + CBMFileResult *extracted = + cbm_extract_file(source, (int)strlen(source), CBM_LANG_PYTHON, "cbm_route_suffix", + "app.py", 0, NULL, NULL); + ASSERT_NOT_NULL(extracted); + ASSERT_GT(count_extracted_calls_named(extracted, "ec.get"), 0); + cbm_free_result(extracted); + + char path[512]; + snprintf(path, sizeof(path), "%s/app.py", dir); + ASSERT_EQ(th_write_file(path, source), 0); + + cbm_file_info_t files[1] = {0}; + files[0].path = path; + files[0].rel_path = (char *)"app.py"; + files[0].language = CBM_LANG_PYTHON; + + cbm_gbuf_t *gbuf = run_parallel("cbm_route_suffix", dir, files, 1, 1); + ASSERT_NOT_NULL(gbuf); + + self_get_call_ctx_t c = {0}; + cbm_gbuf_foreach_edge(gbuf, count_self_get_call_edges, &c); + ASSERT_EQ(c.self_get_calls, 0); + + cbm_gbuf_free(gbuf); + th_rmtree(dir); + PASS(); +} + +typedef struct { + const char *url_path; + int route_registration_calls; +} route_registration_count_ctx_t; + +static void count_route_registration_edges(const cbm_gbuf_edge_t *edge, void *ud) { + route_registration_count_ctx_t *c = ud; + if (!edge || !edge->type || strcmp(edge->type, "CALLS") != 0 || !edge->properties_json) { + return; + } + if (strstr(edge->properties_json, "\"via\":\"route_registration\"") && + strstr(edge->properties_json, c->url_path)) { + c->route_registration_calls++; + } +} + +static int count_route_registration_for_path(cbm_gbuf_t *gbuf, const char *url_path) { + route_registration_count_ctx_t c = {.url_path = url_path, .route_registration_calls = 0}; + cbm_gbuf_foreach_edge(gbuf, count_route_registration_edges, &c); + return c.route_registration_calls; +} + +static void count_exception_edges(const cbm_gbuf_edge_t *edge, void *ud) { + int *count = (int *)ud; + if (!edge || !edge->type || !count) { + return; + } + if (strcmp(edge->type, "THROWS") == 0 || strcmp(edge->type, "RAISES") == 0) { + (*count)++; + } +} + +static int exception_edge_count(cbm_gbuf_t *gbuf) { + int count = 0; + cbm_gbuf_foreach_edge(gbuf, count_exception_edges, &count); + return count; +} + +TEST(parallel_top_level_raise_matches_sequential_no_file_fallback) { + char dir[CBM_PATH_MAX]; + int n = snprintf(dir, sizeof(dir), "%s/cbm_top_raise_XXXXXX", cbm_tmpdir()); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(dir)); + ASSERT_TRUE(cbm_mkdtemp(dir) != NULL); + + const char *source = + "class HTTPException(Exception):\n" + " pass\n\n" + "raise HTTPException()\n"; + + char path[CBM_PATH_MAX]; + n = snprintf(path, sizeof(path), "%s/app.py", dir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(path)); + ASSERT_EQ(th_write_file(path, source), 0); + + cbm_file_info_t files[1] = {0}; + files[0].path = path; + files[0].rel_path = (char *)"app.py"; + files[0].language = CBM_LANG_PYTHON; + + cbm_gbuf_t *seq = run_sequential("cbm_top_raise", dir, files, 1); + cbm_gbuf_t *par = run_parallel("cbm_top_raise", dir, files, 1, 1); + ASSERT_NOT_NULL(seq); + ASSERT_NOT_NULL(par); + + ASSERT_EQ(exception_edge_count(seq), 0); + ASSERT_EQ(exception_edge_count(par), 0); + + cbm_gbuf_free(seq); + cbm_gbuf_free(par); + th_rmtree(dir); + PASS(); +} + +TEST(parallel_fastapi_websocket_route_registration_matches_sequential) { + char dir[256]; + snprintf(dir, sizeof(dir), "/tmp/cbm_ws_routes_XXXXXX"); + ASSERT_TRUE(cbm_mkdtemp(dir) != NULL); + + const char *source = + "from fastapi import APIRouter, WebSocket\n\n" + "router = APIRouter()\n\n" + "@router.websocket('/custom_error/')\n" + "async def router_ws_custom_error(websocket: WebSocket):\n" + " raise RuntimeError('boom')\n\n" + "@router.websocket_route('/router')\n" + "async def routerindex(websocket: WebSocket):\n" + " await websocket.accept()\n"; + + char path[512]; + snprintf(path, sizeof(path), "%s/app.py", dir); + ASSERT_EQ(th_write_file(path, source), 0); + + cbm_file_info_t files[1] = {0}; + files[0].path = path; + files[0].rel_path = (char *)"app.py"; + files[0].language = CBM_LANG_PYTHON; + + cbm_gbuf_t *seq = run_sequential("cbm_ws_routes", dir, files, 1); + cbm_gbuf_t *par = run_parallel("cbm_ws_routes", dir, files, 1, 1); + ASSERT_NOT_NULL(seq); + ASSERT_NOT_NULL(par); + + ASSERT_EQ(count_route_registration_for_path(seq, "/custom_error/"), 1); + ASSERT_EQ(count_route_registration_for_path(seq, "/router"), 1); + ASSERT_EQ(count_route_registration_for_path(par, "/custom_error/"), 1); + ASSERT_EQ(count_route_registration_for_path(par, "/router"), 1); + + cbm_gbuf_free(seq); + cbm_gbuf_free(par); + th_rmtree(dir); + PASS(); +} + +/* ── Production pipeline worker-count parity ─────────────────────── */ + +enum { + PARITY_REPO_FILE_COUNT = 64, + PARITY_EXPECTED_FILE_HASHES = PARITY_REPO_FILE_COUNT + 2, + PARITY_DB_PATH_COUNT = 4, + PARITY_PATH_BUF = CBM_SZ_512, + PARITY_SOURCE_BUF = CBM_SZ_4K, + PARITY_REP_QN_COUNT = 4, +}; + +typedef struct { + int nodes; + int edges; + int file_hashes; + int calls; + int imports; + int usage; + int semantic; + int representative_qns; +} pipeline_db_counts_t; + +static int parity_format(char *dst, size_t dst_sz, const char *fmt, ...) { + if (!dst || dst_sz == 0 || !fmt) { + return CBM_NOT_FOUND; + } + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(dst, dst_sz, fmt, ap); + va_end(ap); + return n >= 0 && (size_t)n < dst_sz ? 0 : CBM_NOT_FOUND; +} + +static void remove_sqlite_family(const char *db_path) { + if (!db_path || !db_path[0]) { + return; + } + cbm_unlink(db_path); + char sidecar[PARITY_PATH_BUF]; + if (parity_format(sidecar, sizeof(sidecar), "%s-wal", db_path) == 0) { + cbm_unlink(sidecar); + } + if (parity_format(sidecar, sizeof(sidecar), "%s-shm", db_path) == 0) { + cbm_unlink(sidecar); + } +} + +static int sqlite_integrity_ok(const char *db_path) { + sqlite3 *db = NULL; + sqlite3_stmt *stmt = NULL; + int ok = 0; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK && db && + sqlite3_prepare_v2(db, "PRAGMA integrity_check;", CBM_NOT_FOUND, &stmt, NULL) == + SQLITE_OK && + sqlite3_step(stmt) == SQLITE_ROW) { + const char *msg = (const char *)sqlite3_column_text(stmt, 0); + ok = msg && strcmp(msg, "ok") == 0; + } + if (stmt) { + sqlite3_finalize(stmt); + } + if (db) { + sqlite3_close(db); + } + return ok; +} + +static int write_worker_parity_repo(char *repo_dir, size_t repo_dir_sz) { + if (!repo_dir || repo_dir_sz == 0) { + return CBM_NOT_FOUND; + } + if (parity_format(repo_dir, repo_dir_sz, "%s/cbm_pipe_parity_XXXXXX", cbm_tmpdir()) != 0) { + return CBM_NOT_FOUND; + } + if (!cbm_mkdtemp(repo_dir)) { + return CBM_NOT_FOUND; + } + + char path[PARITY_PATH_BUF]; + char src[PARITY_SOURCE_BUF]; + + if (parity_format(path, sizeof(path), "%s/common.py", repo_dir) != 0) { + return CBM_NOT_FOUND; + } + if (th_write_file(path, + "def shared(value):\n" + " return value + 1\n" + "\n" + "class Shared:\n" + " def touch(self, value):\n" + " return shared(value)\n") != 0) { + return CBM_NOT_FOUND; + } + + for (int i = 0; i < PARITY_REPO_FILE_COUNT; i++) { + if (parity_format(path, sizeof(path), "%s/mod_%02d.py", repo_dir, i) != 0) { + return CBM_NOT_FOUND; + } + int prev = (i + PARITY_REPO_FILE_COUNT - 1) % PARITY_REPO_FILE_COUNT; + if (parity_format(src, sizeof(src), + "from common import Shared, shared\n" + "from mod_%02d import func_%02d\n" + "\n" + "class Worker%02d:\n" + " def method_%02d(self, value):\n" + " helper = Shared()\n" + " return helper.touch(shared(value))\n" + "\n" + "def func_%02d(value):\n" + " item = Worker%02d()\n" + " return item.method_%02d(value)\n" + "\n" + "def chain_%02d(value):\n" + " return func_%02d(value) + func_%02d(value) + shared(value)\n", + prev, prev, i, i, i, i, i, i, i, prev) != 0) { + return CBM_NOT_FOUND; + } + if (th_write_file(path, src) != 0) { + return CBM_NOT_FOUND; + } + } + + if (parity_format(path, sizeof(path), "%s/app.py", repo_dir) != 0) { + return CBM_NOT_FOUND; + } + FILE *f = fopen(path, "w"); + if (!f) { + return CBM_NOT_FOUND; + } + int write_ok = 1; + for (int i = 0; i < PARITY_REPO_FILE_COUNT; i++) { + write_ok = write_ok && fprintf(f, "from mod_%02d import chain_%02d\n", i, i) >= 0; + } + write_ok = write_ok && fputs("\ndef main():\n total = 0\n", f) >= 0; + for (int i = 0; i < PARITY_REPO_FILE_COUNT; i++) { + write_ok = write_ok && fprintf(f, " total += chain_%02d(%d)\n", i, i) >= 0; + } + write_ok = write_ok && fputs(" return total\n", f) >= 0; + if (fclose(f) != 0) { + write_ok = 0; + } + return write_ok ? 0 : CBM_NOT_FOUND; +} + +static int count_representative_qns(cbm_store_t *store) { + static const char *qns[PARITY_REP_QN_COUNT] = { + "pipe-parity.common.shared", + "pipe-parity.common.Shared.touch", + "pipe-parity.mod_00.func_00", + "pipe-parity.app.main", + }; + int found = 0; + for (int i = 0; i < PARITY_REP_QN_COUNT; i++) { + cbm_node_t node = {0}; + if (cbm_store_find_node_by_qn(store, "pipe-parity", qns[i], &node) == CBM_STORE_OK) { + found++; + cbm_node_free_fields(&node); + } + } + return found; +} + +static int run_pipeline_worker_case(const char *repo_dir, const char *db_path, int workers, + pipeline_db_counts_t *out) { + if (!repo_dir || !db_path || !out) { + return CBM_NOT_FOUND; + } + char worker_buf[CBM_SZ_32]; + if (parity_format(worker_buf, sizeof(worker_buf), "%d", workers) != 0) { + return CBM_NOT_FOUND; + } + if (workers > 0) { + cbm_setenv("CBM_WORKERS", worker_buf, 1); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + + remove_sqlite_family(db_path); + + cbm_pipeline_t *p = cbm_pipeline_new(repo_dir, db_path, CBM_MODE_FULL); + if (!p) { + return CBM_NOT_FOUND; + } + cbm_pipeline_set_project_name(p, "pipe-parity"); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + if (rc != 0 || !sqlite_integrity_ok(db_path)) { + return CBM_NOT_FOUND; + } + + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (!store) { + return CBM_NOT_FOUND; + } + cbm_file_hash_t *hashes = NULL; + int hash_count = 0; + int hash_rc = cbm_store_get_file_hashes(store, "pipe-parity", &hashes, &hash_count); + out->nodes = cbm_store_count_nodes(store, "pipe-parity"); + out->edges = cbm_store_count_edges(store, "pipe-parity"); + out->calls = cbm_store_count_edges_by_type(store, "pipe-parity", "CALLS"); + out->imports = cbm_store_count_edges_by_type(store, "pipe-parity", "IMPORTS"); + out->usage = cbm_store_count_edges_by_type(store, "pipe-parity", "USAGE"); + out->semantic = cbm_store_count_edges_by_type(store, "pipe-parity", "SEMANTICALLY_RELATED"); + out->file_hashes = hash_rc == CBM_STORE_OK ? hash_count : CBM_STORE_ERR; + out->representative_qns = count_representative_qns(store); + cbm_store_free_file_hashes(hashes, hash_count); + + cbm_project_t project = {0}; + int project_rc = cbm_store_get_project(store, "pipe-parity", &project); + int project_root_ok = + project_rc == CBM_STORE_OK && project.root_path && strcmp(project.root_path, repo_dir) == 0; + cbm_project_free_fields(&project); + + cbm_store_close(store); + + return (project_root_ok && out->nodes > 0 && out->edges > 0 && + out->file_hashes == PARITY_EXPECTED_FILE_HASHES && out->calls > 0 && + out->imports > 0 && out->representative_qns == PARITY_REP_QN_COUNT) + ? 0 + : CBM_NOT_FOUND; +} + +static int assert_pipeline_counts_equal(const pipeline_db_counts_t *want, + const pipeline_db_counts_t *got) { + if (!want || !got) { + return CBM_NOT_FOUND; + } + return want->nodes == got->nodes && want->edges == got->edges && + want->file_hashes == got->file_hashes && want->calls == got->calls && + want->imports == got->imports && want->usage == got->usage && + want->semantic == got->semantic && want->representative_qns == got->representative_qns + ? 0 + : CBM_NOT_FOUND; +} + +TEST(parallel_full_pipeline_worker_count_parity_64_files) { + char saved_workers[CBM_SZ_32] = {0}; + bool had_workers = + cbm_safe_getenv("CBM_WORKERS", saved_workers, sizeof(saved_workers), NULL) != NULL; + + char repo_dir[PARITY_PATH_BUF] = {0}; + int rc = write_worker_parity_repo(repo_dir, sizeof(repo_dir)); + + const int workers[PARITY_DB_PATH_COUNT] = {1, 2, 4, 0}; + char db_paths[PARITY_DB_PATH_COUNT][PARITY_PATH_BUF] = {{0}}; + pipeline_db_counts_t counts[PARITY_DB_PATH_COUNT] = {{0}}; + if (rc == 0) { + for (int i = 0; i < PARITY_DB_PATH_COUNT; i++) { + if (parity_format(db_paths[i], sizeof(db_paths[i]), "%s/pipe-parity-%d.db", repo_dir, + i) != 0) { + rc = CBM_NOT_FOUND; + break; + } + if (run_pipeline_worker_case(repo_dir, db_paths[i], workers[i], &counts[i]) != 0) { + rc = CBM_NOT_FOUND; + break; + } + } + } + + if (rc == 0) { + for (int i = 1; i < PARITY_DB_PATH_COUNT; i++) { + if (assert_pipeline_counts_equal(&counts[0], &counts[i]) != 0) { + rc = CBM_NOT_FOUND; + break; + } + } + } + + for (int i = 0; i < PARITY_DB_PATH_COUNT; i++) { + remove_sqlite_family(db_paths[i]); + } + if (repo_dir[0]) { + th_rmtree(repo_dir); + } + if (had_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + + ASSERT_EQ(rc, 0); + PASS(); +} + /* ── Graph buffer merge tests ─────────────────────────────────────── */ TEST(gbuf_shared_ids_unique) { @@ -576,6 +1129,91 @@ TEST(gbuf_next_id_accessors) { PASS(); } +TEST(lsp_resolution_matches_cpp_segments_and_reason_joins) { + CBMResolvedCall items[] = { + {.caller_qn = "proj.C.run", + .callee_qn = "proj.C.doWork", + .strategy = "lsp_type_dispatch", + .confidence = 0.90f, + .reason = NULL}, + {.caller_qn = "proj.C.run", + .callee_qn = "proj.target", + .strategy = "lsp_func_ptr", + .confidence = 0.85f, + .reason = "fp"}, + {.caller_qn = "proj.C.run", + .callee_qn = "proj.C.~C", + .strategy = "lsp_destructor", + .confidence = 0.90f, + .reason = "ptr"}, + }; + CBMResolvedCallArray arr = {.items = items, .count = 3, .cap = 3}; + + CBMCall member_call = {.enclosing_func_qn = "proj.C.run", .callee_name = "obj->doWork"}; + ASSERT(cbm_pipeline_find_lsp_resolution(&arr, &member_call, false) == &items[0]); + + CBMCall scoped_call = {.enclosing_func_qn = "proj.C.run", .callee_name = "ns::doWork"}; + ASSERT(cbm_pipeline_find_lsp_resolution(&arr, &scoped_call, false) == &items[0]); + + CBMCall fp_call = {.enclosing_func_qn = "proj.C.run", .callee_name = "fp"}; + ASSERT(cbm_pipeline_find_lsp_resolution(&arr, &fp_call, false) == &items[1]); + + CBMCall dtor_call = {.enclosing_func_qn = "proj.C.run", .callee_name = "ptr"}; + ASSERT(cbm_pipeline_find_lsp_resolution(&arr, &dtor_call, false) == &items[2]); + + PASS(); +} + +TEST(lsp_resolution_index_matches_linear_cpp_semantics) { + CBMResolvedCall items[] = { + {.caller_qn = "proj.C.run", + .callee_qn = "proj.C.doWork", + .strategy = "lsp_type_dispatch", + .confidence = 0.90f, + .reason = NULL}, + {.caller_qn = "proj.C.run", + .callee_qn = "proj.target", + .strategy = "lsp_func_ptr", + .confidence = 0.85f, + .reason = "fp"}, + }; + CBMResolvedCallArray arr = {.items = items, .count = 2, .cap = 2}; + cbm_lsp_resolution_index_t idx = {0}; + cbm_lsp_resolution_index_build(&idx, &arr, 2, 0.0); + ASSERT_TRUE(idx.complete); + + CBMCall member_call = {.enclosing_func_qn = "proj.C.run", .callee_name = "obj->doWork"}; + ASSERT(cbm_lsp_resolution_index_find(&idx, &arr, &member_call, 0.0, false) == &items[0]); + + CBMCall fp_call = {.enclosing_func_qn = "proj.C.run", .callee_name = "fp"}; + ASSERT(cbm_lsp_resolution_index_find(&idx, &arr, &fp_call, 0.0, false) == &items[1]); + + cbm_lsp_resolution_index_free(&idx); + PASS(); +} + +TEST(lsp_resolution_index_overlong_key_falls_back_to_linear) { + char caller[CBM_SZ_1K + CBM_SZ_128]; + memset(caller, 'a', sizeof(caller) - 1); + caller[sizeof(caller) - 1] = '\0'; + + CBMResolvedCall item = {.caller_qn = caller, + .callee_qn = "proj.target", + .strategy = "lsp_direct", + .confidence = 0.95f, + .reason = NULL}; + CBMResolvedCallArray arr = {.items = &item, .count = 1, .cap = 1}; + cbm_lsp_resolution_index_t idx = {0}; + cbm_lsp_resolution_index_build(&idx, &arr, 1, 0.0); + ASSERT_FALSE(idx.complete); + + CBMCall call = {.enclosing_func_qn = caller, .callee_name = "target"}; + ASSERT(cbm_lsp_resolution_index_find(&idx, &arr, &call, 0.0, false) == &item); + + cbm_lsp_resolution_index_free(&idx); + PASS(); +} + /* ── Parallel-pipeline LSP-override regression ────────────────────── */ /* Pin the wiring fix that unified pass_calls.c (sequential) and * pass_parallel.c (parallel) on cbm_pipeline_find_lsp_resolution + @@ -604,6 +1242,62 @@ static void count_lsp_call_edges(const cbm_gbuf_edge_t *edge, void *ud) { } } +static bool resolved_call_contains(const CBMResolvedCallArray *arr, const char *caller_sub, + const char *callee_sub) { + if (!arr || !caller_sub || !callee_sub) { + return false; + } + for (int i = 0; i < arr->count; i++) { + const CBMResolvedCall *rc = &arr->items[i]; + if (rc->caller_qn && strstr(rc->caller_qn, caller_sub) && rc->callee_qn && + strstr(rc->callee_qn, callee_sub)) { + return true; + } + } + return false; +} + +typedef struct { + const cbm_gbuf_t *gbuf; + const char *source_sub; + const char *target_sub; + const char *props_sub; + bool found; +} call_edge_contains_ctx_t; + +static void call_edge_contains_visit(const cbm_gbuf_edge_t *edge, void *ud) { + call_edge_contains_ctx_t *ctx = ud; + if (!ctx || ctx->found || !edge || !edge->type || strcmp(edge->type, "CALLS") != 0) { + return; + } + const cbm_gbuf_node_t *source = cbm_gbuf_find_by_id(ctx->gbuf, edge->source_id); + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(ctx->gbuf, edge->target_id); + if (!source || !target || !source->qualified_name || !target->qualified_name) { + return; + } + if (strstr(source->qualified_name, ctx->source_sub) && + strstr(target->qualified_name, ctx->target_sub) && + (!ctx->props_sub || + (edge->properties_json && strstr(edge->properties_json, ctx->props_sub)))) { + ctx->found = true; + } +} + +static bool call_edge_contains(const cbm_gbuf_t *gbuf, const char *source_sub, + const char *target_sub, const char *props_sub) { + if (!gbuf || !source_sub || !target_sub) { + return false; + } + call_edge_contains_ctx_t ctx = { + .gbuf = gbuf, + .source_sub = source_sub, + .target_sub = target_sub, + .props_sub = props_sub, + }; + cbm_gbuf_foreach_edge(gbuf, call_edge_contains_visit, &ctx); + return ctx.found; +} + static const char *class_method_tail(const char *qn) { if (!qn) { return NULL; @@ -957,6 +1651,103 @@ TEST(parallel_python_lsp_override_cross_file_emits_lsp_strategy_edges) { PASS(); } +TEST(parallel_cross_lsp_pruning_requires_matching_call_resolution) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_pylsp_prune_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + + char rpath[512]; + snprintf(rpath, sizeof(rpath), "%s/routing.py", tmpdir); + FILE *rf = fopen(rpath, "w"); + if (!rf) { + rmdir(tmpdir); + FAIL("fopen routing.py failed"); + } + fprintf(rf, "class APIRouter:\n" + " def add_api_route(self):\n" + " return None\n" + " def include_router(self):\n" + " self.add_api_route()\n"); + fclose(rf); + + cbm_file_info_t files[1] = {0}; + files[0].path = rpath; + files[0].rel_path = (char *)"routing.py"; + files[0].language = CBM_LANG_PYTHON; + + cbm_gbuf_t *gbuf = cbm_gbuf_new("cbm_par_pylsp_prune", tmpdir); + cbm_registry_t *reg = cbm_registry_new(); + CBMFileResult **result_cache = calloc(1, sizeof(*result_cache)); + ASSERT_NOT_NULL(gbuf); + ASSERT_NOT_NULL(reg); + ASSERT_NOT_NULL(result_cache); + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "cbm_par_pylsp_prune", + .repo_path = tmpdir, + .gbuf = gbuf, + .registry = reg, + .cancelled = &cancelled}; + _Atomic int64_t shared_ids; + atomic_init(&shared_ids, cbm_gbuf_next_id(gbuf)); + + cbm_init(); + ASSERT_EQ(cbm_parallel_extract(&ctx, files, 1, result_cache, &shared_ids, 1), 0); + cbm_gbuf_set_next_id(gbuf, atomic_load(&shared_ids)); + ASSERT_NOT_NULL(result_cache[0]); + ASSERT_GT(result_cache[0]->calls.count, 0); + + result_cache[0]->resolved_calls.count = 0; + CBMResolvedCall unrelated = {.caller_qn = "cbm_par_pylsp_prune.routing.unrelated", + .callee_qn = "cbm_par_pylsp_prune.routing.APIRouter.unrelated", + .strategy = "lsp_method", + .confidence = 0.95f}; + cbm_resolvedcall_push(&result_cache[0]->resolved_calls, &result_cache[0]->arena, unrelated); + ASSERT_EQ(result_cache[0]->resolved_calls.count, result_cache[0]->calls.count); + + ASSERT_EQ(cbm_build_registry_from_cache(&ctx, files, 1, result_cache), 0); + + char **def_modules = calloc(1, sizeof(*def_modules)); + int def_count = 0; + CBMLSPDef *all_defs = + cbm_pxc_collect_all_defs(result_cache, files, 1, ctx.project_name, def_modules, &def_count); + CBMModuleDefIndex *module_def_index = + all_defs ? cbm_pxc_build_module_def_index(all_defs, def_count) : NULL; + ASSERT_NOT_NULL(all_defs); + + ASSERT_EQ(cbm_parallel_resolve(&ctx, files, 1, result_cache, &shared_ids, 1, all_defs, + def_count, def_modules, module_def_index, + NULL /* cross_registries */), + 0); + cbm_gbuf_set_next_id(gbuf, atomic_load(&shared_ids)); + + ASSERT_TRUE(resolved_call_contains(&result_cache[0]->resolved_calls, "include_router", + "add_api_route")); + lsp_edge_count_ctx_t lsp_edges = {0}; + cbm_gbuf_foreach_edge(gbuf, count_lsp_call_edges, &lsp_edges); + ASSERT_GT(lsp_edges.total_calls, 0); + ASSERT_GT(lsp_edges.lsp_strategy_count, 0); + ASSERT_TRUE(call_edge_contains(gbuf, "APIRouter.include_router", "APIRouter.add_api_route", + "\"strategy\":\"lsp_method\"")); + + cbm_pxc_free_module_def_index(module_def_index); + free(all_defs); + if (def_modules) { + free(def_modules[0]); + free(def_modules); + } + cbm_free_result(result_cache[0]); + free(result_cache); + cbm_registry_free(reg); + cbm_gbuf_free(gbuf); + unlink(rpath); + rmdir(tmpdir); + PASS(); +} + /* RED/GREEN A — the graph-quality guarantee behind the low-RAM retention cap. * * The fused cross-file LSP step re-parses each file's source to resolve calls @@ -1208,6 +1999,7 @@ TEST(lsp_resolve_misattribution_is_bounded) { /* ── Suite Registration ──────────────────────────────────────────── */ SUITE(parallel) { + RUN_TEST(parallel_cbm_init_concurrent_idempotent); RUN_TEST(lsp_resolve_qualified_static_call_normalizes_colons); RUN_TEST(lsp_resolve_misattribution_is_bounded); RUN_TEST(grpc_service_name_preserves_service_suffix_issue294); @@ -1219,11 +2011,15 @@ SUITE(parallel) { RUN_TEST(gbuf_merge_empty_src); RUN_TEST(gbuf_merge_src_free_safe); RUN_TEST(gbuf_next_id_accessors); + RUN_TEST(lsp_resolution_matches_cpp_segments_and_reason_joins); + RUN_TEST(lsp_resolution_index_matches_linear_cpp_semantics); + RUN_TEST(lsp_resolution_index_overlong_key_falls_back_to_linear); /* Parallel pipeline parity tests */ RUN_TEST(parallel_node_count); RUN_TEST(parallel_python_lsp_override_emits_lsp_strategy_edges); RUN_TEST(parallel_python_lsp_override_cross_file_emits_lsp_strategy_edges); + RUN_TEST(parallel_cross_lsp_pruning_requires_matching_call_resolution); RUN_TEST(parallel_cross_file_reread_preserves_unretained_edges); RUN_TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges); RUN_TEST(parallel_lsp_tail_match_fallbacks_gated_to_jvm); @@ -1236,8 +2032,13 @@ SUITE(parallel) { RUN_TEST(parallel_implements_parity); RUN_TEST(parallel_semantic_fixture_expected_counts); RUN_TEST(parallel_total_edges); + RUN_TEST(parallel_full_pipeline_worker_count_parity_64_files); RUN_TEST(parallel_empty_files); + RUN_TEST(extraction_errors_are_nonfatal_in_parallel_and_sequential_paths); RUN_TEST(parallel_args_json_no_overflow); + RUN_TEST(parallel_unresolved_route_suffix_does_not_emit_self_call); + RUN_TEST(parallel_top_level_raise_matches_sequential_no_file_fallback); + RUN_TEST(parallel_fastapi_websocket_route_registration_matches_sequential); /* Cleanup shared state */ parity_teardown(); diff --git a/tests/test_parallel_harness_contract.sh b/tests/test_parallel_harness_contract.sh index a658dc56a..9f8e2c312 100755 --- a/tests/test_parallel_harness_contract.sh +++ b/tests/test_parallel_harness_contract.sh @@ -30,6 +30,35 @@ if ! grep -Fq 'run-test-wave.py' "$driver"; then exit 1 fi +assignment_body() { + local name="$1" + awk -v name="$name" ' + $0 ~ "^" name "=\"" { capture = 1 } + capture { + print + if ($0 !~ /\\$/) { + exit + } + } + ' "$driver" +} + +# These suites assert absolute wall-clock ceilings. They must stay out of both +# concurrent waves: running one alone is part of the measurement contract, not +# a tolerance increase for a loaded scheduler. +serial_body="$(assignment_body SERIAL_SUITES)" +exclusive_body="$(assignment_body TAIL_EXCL)" +for suite in cs_lsp_bench py_lsp_bench py_lsp_scale; do + if ! grep -Eq "(^|[[:space:]])${suite}([[:space:]\"\\\\]|$)" <<<"$serial_body"; then + echo "FAIL: $suite can enter the main concurrent suite wave" >&2 + exit 1 + fi + if ! grep -Eq "(^|[[:space:]])${suite}([[:space:]\"\\\\]|$)" <<<"$exclusive_body"; then + echo "FAIL: $suite can enter the concurrent serial-tail wave" >&2 + exit 1 + fi +done + cat >"$fixture/fake_runner.py" <<'PY' from __future__ import annotations diff --git a/tests/test_parent_watchdog.sh b/tests/test_parent_watchdog.sh index 87f938d6e..601b28c81 100755 --- a/tests/test_parent_watchdog.sh +++ b/tests/test_parent_watchdog.sh @@ -12,6 +12,11 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BINARY="${CBM_TEST_BINARY:-${ROOT}/build/c/codebase-memory-mcp}" +CHILD_START_ATTEMPTS=50 +CHILD_START_POLL_SECONDS=0.1 +CHILD_READY_LOG_PATTERN='msg=mem.init' +WATCHDOG_EXIT_TIMEOUT_SECONDS=6 +WATCHDOG_EXIT_POLL_SECONDS=0.2 case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) @@ -56,9 +61,9 @@ CBM_BINARY="${BINARY}" FIFO="${tmpdir}/stdin" TMPDIR_PATH="${tmpdir}" \ wrapper_pid=$! # Wait for the child PID file to appear. -for _ in {1..50}; do +for ((attempt = 0; attempt < CHILD_START_ATTEMPTS; attempt++)); do [[ -s "${tmpdir}/child.pid" ]] && break - sleep 0.1 + sleep "${CHILD_START_POLL_SECONDS}" done if [[ ! -s "${tmpdir}/child.pid" ]]; then @@ -85,7 +90,7 @@ for _ in {1..150}; do grep -Eq '"id"[[:space:]]*:[[:space:]]*1' "${tmpdir}/child.out"; then break fi - sleep 0.1 + sleep "${CHILD_START_POLL_SECONDS}" done if ! grep -Eq '"id"[[:space:]]*:[[:space:]]*1' "${tmpdir}/child.out" 2>/dev/null; then echo "child did not reach watchdog-ready startup point" >&2 @@ -98,7 +103,7 @@ fi kill -9 "${wrapper_pid}" wait "${wrapper_pid}" 2>/dev/null || true -deadline=$((SECONDS + 15)) +deadline=$((SECONDS + WATCHDOG_EXIT_TIMEOUT_SECONDS)) while (( SECONDS < deadline )); do if ! kill -0 "${child_pid}" 2>/dev/null; then echo "ok: child ${child_pid} exited after parent death" @@ -111,7 +116,7 @@ while (( SECONDS < deadline )); do echo "ok: child ${child_pid} exited after parent death (zombie awaiting reap)" exit 0 fi - sleep 0.2 + sleep "${WATCHDOG_EXIT_POLL_SECONDS}" done echo "codebase-memory-mcp child ${child_pid} survived parent death" >&2 diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c index 7d3645ffb..c393170f4 100644 --- a/tests/test_parse_coverage.c +++ b/tests/test_parse_coverage.c @@ -198,12 +198,11 @@ TEST(py_clean_file_not_flagged) { PASS(); } -TEST(error_region_cap_is_honored) { +TEST(error_regions_are_not_silently_capped) { /* Pathological input: many separate unrecoverable garbage blocks - * interleaved with valid defs. The collector must stay bounded by its - * 64-region cap (matches CBM_MAX_ERROR_REGIONS in cbm.c) — pathological - * input can't blow up the report, and the flag itself still fires. */ - enum { GARBAGE_BLOCKS = 200, LINE_CAP = 64 }; + * interleaved with valid defs. Every detected region must be reported; + * storage remains bounded by the already-materialized parse tree. */ + enum { GARBAGE_BLOCKS = 200, FORMER_REGION_CAP = 64 }; char *src = (char *)malloc(GARBAGE_BLOCKS * 96 + 1); ASSERT_NOT_NULL(src); size_t off = 0; @@ -215,9 +214,15 @@ TEST(error_region_cap_is_honored) { free(src); ASSERT_NOT_NULL(r); ASSERT_TRUE(r->parse_incomplete); - ASSERT_GTE(r->error_region_count, 1); - ASSERT_LTE(r->error_region_count, LINE_CAP); + ASSERT_GT(r->error_region_count, FORMER_REGION_CAP); ASSERT_NOT_NULL(r->error_ranges); + int serialized_regions = 1; + for (const char *p = r->error_ranges; *p; p++) { + if (*p == ',') { + serialized_regions++; + } + } + ASSERT_EQ(serialized_regions, r->error_region_count); cbm_free_result(r); PASS(); } @@ -255,6 +260,6 @@ SUITE(parse_coverage) { RUN_TEST(py_unrecovered_garbage_sets_parse_incomplete); RUN_TEST(py_recovered_def_not_flagged); RUN_TEST(py_clean_file_not_flagged); - RUN_TEST(error_region_cap_is_honored); + RUN_TEST(error_regions_are_not_silently_capped); RUN_TEST(c_trailing_recovered_defs_keep_flag); } diff --git a/tests/test_path_alias.c b/tests/test_path_alias.c index a0bdc8936..9872de7a1 100644 --- a/tests/test_path_alias.c +++ b/tests/test_path_alias.c @@ -9,6 +9,7 @@ #include "test_framework.h" #include "../src/pipeline/path_alias.h" #include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" #include #include @@ -17,6 +18,23 @@ #include #include +enum { + PATH_ALIAS_PARENT_DEPTH_CAP = 32, + PATH_ALIAS_DEEP_FIXTURE_DEPTH = PATH_ALIAS_PARENT_DEPTH_CAP + 4, + PATH_ALIAS_LONG_FIXTURE_SEGMENTS = 5, + PATH_ALIAS_LONG_FIXTURE_SEGMENT_BYTES = 100, + PATH_ALIAS_PARENT_ENTRY_CAP = 256, + PATH_ALIAS_COMPLETE_ENTRY_COUNT = PATH_ALIAS_PARENT_ENTRY_CAP + 1, + PATH_ALIAS_PARENT_CONFIG_CAP = 256, + PATH_ALIAS_COMPLETE_CONFIG_COUNT = PATH_ALIAS_PARENT_CONFIG_CAP + 1, + PATH_ALIAS_CHILD_CONFIG_COUNT = PATH_ALIAS_COMPLETE_CONFIG_COUNT - 1, + PATH_ALIAS_SCOPE_NAME_BYTES = 32, + PATH_ALIAS_PARENT_FILE_CAP_BYTES = 64 * 1024, + PATH_ALIAS_LARGE_CONFIG_PADDING_BYTES = PATH_ALIAS_PARENT_FILE_CAP_BYTES + 1024, + PATH_ALIAS_ENTRY_JSON_BYTES = 80, + PATH_ALIAS_FIXTURE_DIR_MODE = 0700, +}; + /* Build a path alias map programmatically (no file I/O), respecting the * specificity ordering invariant the loader establishes via qsort. */ static cbm_path_alias_map_t *make_map(const char *base_url, int count, ...) { @@ -112,8 +130,7 @@ TEST(path_alias_at_nested) { TEST(path_alias_specificity_longest_first) { // @/lib/* must beat @/* even though @/* would also match. - cbm_path_alias_map_t *m = - make_map(NULL, 2, "@/*", "src/*", "@/lib/*", "src/shared/lib/*"); + cbm_path_alias_map_t *m = make_map(NULL, 2, "@/*", "src/*", "@/lib/*", "src/shared/lib/*"); char *r = cbm_path_alias_resolve(m, "@/lib/auth"); ASSERT_NOT_NULL(r); ASSERT_STR_EQ(r, "src/shared/lib/auth"); @@ -205,8 +222,7 @@ TEST(path_alias_find_for_file_nearest_ancestor) { ASSERT_EQ(m1->count, 1); ASSERT_STR_EQ(m1->entries[0].alias_prefix, "@/"); - const cbm_path_alias_map_t *m2 = - cbm_path_alias_find_for_file(coll, "packages/utils/index.ts"); + const cbm_path_alias_map_t *m2 = cbm_path_alias_find_for_file(coll, "packages/utils/index.ts"); ASSERT_NOT_NULL(m2); ASSERT_EQ(m2->count, 1); ASSERT_STR_EQ(m2->entries[0].alias_prefix, "@root/"); @@ -218,7 +234,7 @@ TEST(path_alias_find_for_file_nearest_ancestor) { /* ── End-to-end via the loader: real tsconfig in a tmp dir ─────── */ static int write_file(const char *path, const char *content) { - FILE *f = fopen(path, "w"); + FILE *f = cbm_fopen(path, "w"); if (!f) { return -1; } @@ -228,8 +244,371 @@ static int write_file(const char *path, const char *content) { return rc; } +typedef struct { + char *root; + char **dirs; + size_t dir_count; + char *rel_dir; + char *config_path; +} path_alias_tree_fixture_t; + +static char *path_alias_join(const char *left, const char *right) { + size_t left_len = strlen(left); + size_t right_len = strlen(right); + size_t separator = left_len > 0 ? 1U : 0U; + if (left_len > SIZE_MAX - right_len || left_len + right_len > SIZE_MAX - separator - 1U) { + return NULL; + } + size_t total = left_len + separator + right_len; + char *result = malloc(total + 1U); + if (!result) { + return NULL; + } + memcpy(result, left, left_len); + if (separator > 0) { + result[left_len] = '/'; + } + memcpy(result + left_len + separator, right, right_len + 1U); + return result; +} + +static void path_alias_tree_fixture_free(path_alias_tree_fixture_t *fixture) { + if (!fixture) { + return; + } + if (fixture->config_path) { + unlink(fixture->config_path); + } + for (size_t i = fixture->dir_count; i > 0; i--) { + rmdir(fixture->dirs[i - 1U]); + free(fixture->dirs[i - 1U]); + } + if (fixture->root) { + rmdir(fixture->root); + } + free(fixture->config_path); + free(fixture->rel_dir); + free(fixture->dirs); + free(fixture->root); + memset(fixture, 0, sizeof(*fixture)); +} + +static bool path_alias_tree_fixture_create(path_alias_tree_fixture_t *fixture, size_t segment_count, + size_t segment_bytes, const char *config) { + memset(fixture, 0, sizeof(*fixture)); + /* Windows cbm_mkdtemp expands /tmp through %TEMP%; retain the centralized + * capacity contract so a long or Unicode temporary root cannot overwrite + * this stack buffer. Runtime and auxiliary memory remain O(1). */ + char tmpl[CBM_SZ_256] = "/tmp/cbm_palias_exact_XXXXXX"; + char *root = cbm_mkdtemp(tmpl); + if (!root) { + return false; + } + fixture->root = strdup(root); + if (!fixture->root) { + rmdir(root); + return false; + } + fixture->dirs = calloc(segment_count, sizeof(*fixture->dirs)); + char *current_abs = strdup(fixture->root); + char *current_rel = strdup(""); + char *segment = segment_bytes < SIZE_MAX ? malloc(segment_bytes + 1U) : NULL; + if ((segment_count > 0 && !fixture->dirs) || !current_abs || !current_rel || !segment) { + free(segment); + free(current_rel); + free(current_abs); + path_alias_tree_fixture_free(fixture); + return false; + } + memset(segment, 'd', segment_bytes); + segment[segment_bytes] = '\0'; + + for (size_t i = 0; i < segment_count; i++) { + char *next_abs = path_alias_join(current_abs, segment); + char *next_rel = path_alias_join(current_rel, segment); + /* Reuse the production UTF-8/extended-length directory owner. Each + * call adds one component, so fixture creation remains O(total path + * bytes) live memory and O(segment_count * final path bytes) time. */ + if (!next_abs || !next_rel || + !cbm_mkdir_p(next_abs, PATH_ALIAS_FIXTURE_DIR_MODE)) { + free(next_rel); + free(next_abs); + free(segment); + free(current_rel); + free(current_abs); + path_alias_tree_fixture_free(fixture); + return false; + } + fixture->dirs[fixture->dir_count++] = next_abs; + free(current_abs); + free(current_rel); + current_abs = strdup(next_abs); + current_rel = next_rel; + if (!current_abs) { + free(segment); + free(current_rel); + path_alias_tree_fixture_free(fixture); + return false; + } + } + free(segment); + fixture->rel_dir = current_rel; + fixture->config_path = path_alias_join(current_abs, "tsconfig.json"); + free(current_abs); + if (!fixture->config_path || write_file(fixture->config_path, config) != 0) { + path_alias_tree_fixture_free(fixture); + return false; + } + return true; +} + +static bool path_alias_fixture_resolves(path_alias_tree_fixture_t *fixture, const char *module, + const char *expected) { + cbm_path_alias_collection_t *coll = cbm_load_path_aliases(fixture->root); + char *source = path_alias_join(fixture->rel_dir, "consumer.ts"); + const cbm_path_alias_map_t *map = + coll && source ? cbm_path_alias_find_for_file(coll, source) : NULL; + char *resolved = map ? cbm_path_alias_resolve(map, module) : NULL; + bool exact = resolved && strcmp(resolved, expected) == 0; + free(resolved); + free(source); + cbm_path_alias_collection_free(coll); + return exact; +} + +static char *path_alias_many_entries_config(void) { + const char prefix[] = "{\"compilerOptions\":{\"paths\":{"; + const char suffix[] = "}}}"; + if ((size_t)PATH_ALIAS_COMPLETE_ENTRY_COUNT > + (SIZE_MAX - sizeof(prefix) - sizeof(suffix)) / PATH_ALIAS_ENTRY_JSON_BYTES) { + return NULL; + } + size_t capacity = sizeof(prefix) + sizeof(suffix) + + (size_t)PATH_ALIAS_COMPLETE_ENTRY_COUNT * PATH_ALIAS_ENTRY_JSON_BYTES; + char *config = malloc(capacity); + if (!config) { + return NULL; + } + int prefix_written = snprintf(config, capacity, "%s", prefix); + if (prefix_written < 0 || (size_t)prefix_written >= capacity) { + free(config); + return NULL; + } + size_t used = (size_t)prefix_written; + for (int i = 0; i < PATH_ALIAS_COMPLETE_ENTRY_COUNT; i++) { + int written = + snprintf(config + used, capacity - used, "\"@alias%d/*\":[\"src/alias%d/*\"]%s", i, i, + i + 1 < PATH_ALIAS_COMPLETE_ENTRY_COUNT ? "," : ""); + if (written < 0 || (size_t)written >= capacity - used) { + free(config); + return NULL; + } + used += (size_t)written; + } + if (snprintf(config + used, capacity - used, "%s", suffix) < 0) { + free(config); + return NULL; + } + return config; +} + +static char *path_alias_large_config(void) { + const char prefix[] = "{\"compilerOptions\":{"; + const char suffix[] = "\"paths\":{\"@/*\":[\"src/*\"]}}}"; + size_t padding = PATH_ALIAS_LARGE_CONFIG_PADDING_BYTES; + if (padding > SIZE_MAX - sizeof(prefix) - sizeof(suffix)) { + return NULL; + } + size_t total = (sizeof(prefix) - 1U) + padding + sizeof(suffix); + char *config = malloc(total); + if (!config) { + return NULL; + } + memcpy(config, prefix, sizeof(prefix) - 1U); + memset(config + sizeof(prefix) - 1U, ' ', padding); + memcpy(config + sizeof(prefix) - 1U + padding, suffix, sizeof(suffix)); + return config; +} + +TEST(path_alias_paths_targets_respect_baseurl) { + const char config[] = + "{\"compilerOptions\":{\"baseUrl\":\"src\",\"paths\":{\"@/*\":[\"components/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, 0, 0, config)); + bool exact = path_alias_fixture_resolves(&fixture, "@/Button", "src/components/Button"); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(path_alias_loader_reaches_beyond_parent_depth_cap) { + const char config[] = "{\"compilerOptions\":{\"paths\":{\"@/*\":[\"src/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, PATH_ALIAS_DEEP_FIXTURE_DEPTH, 1, config)); + char *expected = path_alias_join(fixture.rel_dir, "src/value"); + bool exact = expected && path_alias_fixture_resolves(&fixture, "@/value", expected); + free(expected); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(path_alias_loader_preserves_paths_beyond_parent_buffers) { + const char config[] = "{\"compilerOptions\":{\"paths\":{\"@/*\":[\"src/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, PATH_ALIAS_LONG_FIXTURE_SEGMENTS, + PATH_ALIAS_LONG_FIXTURE_SEGMENT_BYTES, config)); + char *expected = path_alias_join(fixture.rel_dir, "src/value"); + bool exact = expected && path_alias_fixture_resolves(&fixture, "@/value", expected); + free(expected); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(path_alias_loader_uses_shared_file_size_policy) { + char *config = path_alias_large_config(); + ASSERT_NOT_NULL(config); + path_alias_tree_fixture_t fixture; + bool created = path_alias_tree_fixture_create(&fixture, 0, 0, config); + free(config); + ASSERT_TRUE(created); + bool exact = path_alias_fixture_resolves(&fixture, "@/value", "src/value"); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(path_alias_loader_retains_every_configured_entry) { + char *config = path_alias_many_entries_config(); + ASSERT_NOT_NULL(config); + path_alias_tree_fixture_t fixture; + bool created = path_alias_tree_fixture_create(&fixture, 0, 0, config); + free(config); + ASSERT_TRUE(created); + bool exact = path_alias_fixture_resolves(&fixture, "@alias256/value", "src/alias256/value"); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(path_alias_loader_retains_every_config_file) { + const char config[] = "{\"compilerOptions\":{\"paths\":{\"@/*\":[\"src/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, 0, 0, config)); + char **dirs = calloc(PATH_ALIAS_CHILD_CONFIG_COUNT, sizeof(*dirs)); + char **configs = calloc(PATH_ALIAS_CHILD_CONFIG_COUNT, sizeof(*configs)); + size_t created = 0; + bool complete = dirs && configs; + while (complete && created < PATH_ALIAS_CHILD_CONFIG_COUNT) { + char name[PATH_ALIAS_SCOPE_NAME_BYTES]; + int written = snprintf(name, sizeof(name), "scope%03zu", created); + dirs[created] = written > 0 && (size_t)written < sizeof(name) + ? path_alias_join(fixture.root, name) + : NULL; + configs[created] = dirs[created] ? path_alias_join(dirs[created], "tsconfig.json") : NULL; + complete = dirs[created] && configs[created] && cbm_mkdir(dirs[created]) == 0 && + write_file(configs[created], config) == 0; + if (complete) { + created++; + } + } + + cbm_path_alias_collection_t *coll = complete ? cbm_load_path_aliases(fixture.root) : NULL; + bool exact = coll && coll->count == PATH_ALIAS_COMPLETE_CONFIG_COUNT; + cbm_path_alias_collection_free(coll); + for (size_t i = created; i > 0; i--) { + unlink(configs[i - 1U]); + rmdir(dirs[i - 1U]); + free(configs[i - 1U]); + free(dirs[i - 1U]); + } + if (!complete && created < PATH_ALIAS_CHILD_CONFIG_COUNT) { + if (configs) { + unlink(configs[created]); + free(configs[created]); + } + if (dirs) { + rmdir(dirs[created]); + free(dirs[created]); + } + } + free(configs); + free(dirs); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(complete); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(path_alias_loader_config_hit_allocation_failure_is_atomic) { + const char config[] = "{\"compilerOptions\":{\"paths\":{\"@/*\":[\"src/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, 0, 0, config)); + char *child = path_alias_join(fixture.root, "child"); + char *child_config = child ? path_alias_join(child, "tsconfig.json") : NULL; + bool complete = + child && child_config && cbm_mkdir(child) == 0 && write_file(child_config, config) == 0; + ASSERT_TRUE(complete); + + /* The root config is stored first. Failure while retaining the child must + * discard that root hit instead of publishing a plausible partial view. */ + cbm_path_alias_test_fail_allocation(CBM_PATH_ALIAS_TEST_ALLOC_CONFIG_HIT, 1); + cbm_path_alias_collection_t *coll = cbm_load_path_aliases(fixture.root); + cbm_path_alias_test_fail_allocation(CBM_PATH_ALIAS_TEST_ALLOC_NONE, -1); + bool failure_is_atomic = coll == NULL; + cbm_path_alias_collection_free(coll); + + unlink(child_config); + rmdir(child); + free(child_config); + free(child); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(failure_is_atomic); + PASS(); +} + +TEST(path_alias_loader_scope_allocation_failure_is_atomic) { + const char config[] = "{\"compilerOptions\":{\"paths\":{\"@/*\":[\"src/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, 0, 0, config)); + + cbm_path_alias_test_fail_allocation(CBM_PATH_ALIAS_TEST_ALLOC_SCOPE_PREFIX, 0); + cbm_path_alias_collection_t *coll = cbm_load_path_aliases(fixture.root); + cbm_path_alias_test_fail_allocation(CBM_PATH_ALIAS_TEST_ALLOC_NONE, -1); + bool failure_is_atomic = coll == NULL; + cbm_path_alias_collection_free(coll); + + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(failure_is_atomic); + PASS(); +} + +TEST(path_alias_loader_rejects_posix_symlink_cycle) { +#ifdef _WIN32 + PASS(); +#else + const char config[] = "{\"compilerOptions\":{\"paths\":{\"@/*\":[\"src/*\"]}}}"; + path_alias_tree_fixture_t fixture; + ASSERT_TRUE(path_alias_tree_fixture_create(&fixture, 0, 0, config)); + char *loop = path_alias_join(fixture.root, "loop"); + bool linked = loop && symlink(fixture.root, loop) == 0; + cbm_path_alias_collection_t *coll = linked ? cbm_load_path_aliases(fixture.root) : NULL; + bool exact = coll && coll->count == 1; + cbm_path_alias_collection_free(coll); + if (loop) { + unlink(loop); + } + free(loop); + path_alias_tree_fixture_free(&fixture); + ASSERT_TRUE(linked); + ASSERT_TRUE(exact); + PASS(); +#endif +} + TEST(path_alias_loader_monorepo) { - char tmpl[256]; + char tmpl[CBM_SZ_256]; snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_XXXXXX"); char *root = cbm_mkdtemp(tmpl); ASSERT_NOT_NULL(root); @@ -242,14 +621,12 @@ TEST(path_alias_loader_monorepo) { char path[512]; snprintf(path, sizeof(path), "%s/tsconfig.json", root); - ASSERT_EQ(write_file(path, - "{\n \"compilerOptions\": {\n \"paths\": {\n" - " \"@root/*\": [\"shared/*\"]\n }\n }\n}\n"), + ASSERT_EQ(write_file(path, "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@root/*\": [\"shared/*\"]\n }\n }\n}\n"), 0); snprintf(path, sizeof(path), "%s/apps/manager/tsconfig.json", root); - ASSERT_EQ(write_file(path, - "{\n // monorepo subpackage\n \"compilerOptions\": {\n" - " \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n },\n}\n"), + ASSERT_EQ(write_file(path, "{\n // monorepo subpackage\n \"compilerOptions\": {\n" + " \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n },\n}\n"), 0); cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root); @@ -292,7 +669,7 @@ TEST(path_alias_loader_monorepo) { /* ── Monorepo alias climbing out of its tsconfig's directory (#730) ── */ TEST(path_alias_loader_monorepo_dotdot_climb) { - char tmpl[256]; + char tmpl[CBM_SZ_256]; snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_climb_XXXXXX"); char *root = cbm_mkdtemp(tmpl); ASSERT_NOT_NULL(root); @@ -305,17 +682,15 @@ TEST(path_alias_loader_monorepo_dotdot_climb) { char path[512]; snprintf(path, sizeof(path), "%s/apps/web/tsconfig.json", root); - ASSERT_EQ(write_file(path, - "{\n \"compilerOptions\": {\n \"paths\": {\n" - " \"@shared/*\": [\"../../packages/shared/src/*\"]\n" - " }\n }\n}\n"), + ASSERT_EQ(write_file(path, "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@shared/*\": [\"../../packages/shared/src/*\"]\n" + " }\n }\n}\n"), 0); cbm_path_alias_collection_t *coll = cbm_load_path_aliases(root); ASSERT_NOT_NULL(coll); - const cbm_path_alias_map_t *m = - cbm_path_alias_find_for_file(coll, "apps/web/src/feature/x.ts"); + const cbm_path_alias_map_t *m = cbm_path_alias_find_for_file(coll, "apps/web/src/feature/x.ts"); ASSERT_NOT_NULL(m); char *r = cbm_path_alias_resolve(m, "@shared/utils"); ASSERT_NOT_NULL(r); @@ -342,7 +717,7 @@ TEST(path_alias_loader_monorepo_dotdot_climb) { * Control run first (no exclusions → both configs collected) so the * exclusion assertion below cannot pass vacuously. */ TEST(path_alias_loader_honors_discovery_exclusions) { - char tmpl[256]; + char tmpl[CBM_SZ_256]; snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_excl_XXXXXX"); char *root = cbm_mkdtemp(tmpl); ASSERT_NOT_NULL(root); @@ -353,14 +728,12 @@ TEST(path_alias_loader_honors_discovery_exclusions) { char path[512]; snprintf(path, sizeof(path), "%s/tsconfig.json", root); - ASSERT_EQ(write_file(path, - "{\n \"compilerOptions\": {\n \"paths\": {\n" - " \"@root/*\": [\"shared/*\"]\n }\n }\n}\n"), + ASSERT_EQ(write_file(path, "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@root/*\": [\"shared/*\"]\n }\n }\n}\n"), 0); snprintf(path, sizeof(path), "%s/big_generated/tsconfig.json", root); - ASSERT_EQ(write_file(path, - "{\n \"compilerOptions\": {\n \"paths\": {\n" - " \"@gen/*\": [\"./src/*\"]\n }\n }\n}\n"), + ASSERT_EQ(write_file(path, "{\n \"compilerOptions\": {\n \"paths\": {\n" + " \"@gen/*\": [\"./src/*\"]\n }\n }\n}\n"), 0); /* Control: the unexcluded loader collects BOTH configs. */ @@ -395,7 +768,7 @@ TEST(path_alias_loader_honors_discovery_exclusions) { /* ── Loader returns NULL when no configs found ─────────────────── */ TEST(path_alias_loader_no_configs) { - char tmpl[256]; + char tmpl[CBM_SZ_256]; snprintf(tmpl, sizeof(tmpl), "/tmp/cbm_palias_empty_XXXXXX"); char *root = cbm_mkdtemp(tmpl); ASSERT_NOT_NULL(root); @@ -417,6 +790,15 @@ void suite_path_alias(void) { RUN_TEST(path_alias_baseurl_fallback); RUN_TEST(path_alias_null_safety); RUN_TEST(path_alias_find_for_file_nearest_ancestor); + RUN_TEST(path_alias_paths_targets_respect_baseurl); + RUN_TEST(path_alias_loader_reaches_beyond_parent_depth_cap); + RUN_TEST(path_alias_loader_preserves_paths_beyond_parent_buffers); + RUN_TEST(path_alias_loader_uses_shared_file_size_policy); + RUN_TEST(path_alias_loader_retains_every_configured_entry); + RUN_TEST(path_alias_loader_retains_every_config_file); + RUN_TEST(path_alias_loader_config_hit_allocation_failure_is_atomic); + RUN_TEST(path_alias_loader_scope_allocation_failure_is_atomic); + RUN_TEST(path_alias_loader_rejects_posix_symlink_cycle); RUN_TEST(path_alias_loader_monorepo); RUN_TEST(path_alias_loader_monorepo_dotdot_climb); RUN_TEST(path_alias_loader_honors_discovery_exclusions); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 9d72b4f58..660c105e9 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5,22 +5,37 @@ * on a temporary directory with known file layout. */ #include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" +#include "../src/foundation/constants.h" #include "foundation/platform.h" // cbm_normalize_path_sep (drive-canonicalization regression) #include "test_framework.h" #include "test_helpers.h" #include "foundation/mem.h" // cbm_mem_init/budget (back-pressure futile-nap test) #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" +#include "pipeline/pass_lsp_cross.h" #include "store/store.h" +#include "cli/cli.h" +#include "git/git_command.h" #include "git/git_context.h" #include "foundation/dump_verify.h" +#include "foundation/log.h" +#include "semantic/semantic.h" +#include "pagerank/pagerank.h" +#include "test_graph_diff.h" +#include #include #include #include #include "foundation/compat_thread.h" #include #include +#ifdef _WIN32 +#include +#else +#include +#endif #include #include "graph_buffer/graph_buffer.h" #include "yyjson/yyjson.h" @@ -30,6 +45,36 @@ static char g_tmpdir[256]; +enum { PIPELINE_TEST_OVERLONG_DB_PATH = CBM_PATH_MAX + CBM_SZ_128 }; + +static char g_pipeline_log_capture[CBM_SZ_64K]; +static CBMLogLevel g_pipeline_prev_log_level = CBM_LOG_INFO; + +static void pipeline_capture_log_sink(const char *line) { + size_t used = strlen(g_pipeline_log_capture); + size_t avail = sizeof(g_pipeline_log_capture) - used; + if (avail <= SKIP_ONE) { + return; + } + int n = snprintf(g_pipeline_log_capture + used, avail, "%s\n", line); + if (n < 0 || (size_t)n >= avail) { + g_pipeline_log_capture[sizeof(g_pipeline_log_capture) - SKIP_ONE] = '\0'; + } +} + +static void pipeline_capture_logs_start(void) { + g_pipeline_log_capture[0] = '\0'; + g_pipeline_prev_log_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_DEBUG); + cbm_log_set_sink(pipeline_capture_log_sink); +} + +static const char *pipeline_capture_logs_end(void) { + cbm_log_set_sink(NULL); + cbm_log_set_level(g_pipeline_prev_log_level); + return g_pipeline_log_capture; +} + /* Create: * /tmp/cbm_test_XXXXXX/ * main.go (empty) @@ -218,6 +263,17 @@ TEST(store_bulk_persistence) { /* ── Integration: structure pass on temp repo ────────────────────── */ +static bool pipeline_test_derived_status_is(cbm_store_t *s, const char *project, + const char *view_name, const char *status) { + cbm_derived_view_state_t state = {0}; + bool matches = false; + if (cbm_store_get_derived_view_state(s, project, view_name, &state) == CBM_STORE_OK) { + matches = state.status && strcmp(state.status, status) == 0; + } + cbm_store_derived_view_state_free_fields(&state); + return matches; +} + TEST(pipeline_structure_nodes) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -268,6 +324,12 @@ TEST(pipeline_structure_nodes) { /* Verify edges exist */ int edge_count = cbm_store_count_edges(s, project); ASSERT_GTE(edge_count, 5); /* CONTAINS_FOLDER + CONTAINS_FILE edges */ + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_STATUS_COMPLETE)); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + CBM_STORE_DERIVED_STATUS_COMPLETE)); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_STATUS_COMPLETE)); cbm_store_close(s); cbm_pipeline_free(p); @@ -275,12 +337,58 @@ TEST(pipeline_structure_nodes) { PASS(); } -/* Issue #516: an ADR stored via manage_adr (project_summaries) must survive a - * full re-index. A full re-index deletes the DB and rebuilds it from the graph - * buffer, which writes an empty project_summaries table; the fix captures the - * ADR before the delete and restores it after the rebuild. Reproduce-first: - * index, store an ADR, force a full re-index by adding files, assert the ADR - * is still present and unchanged. */ +TEST(pipeline_full_reindex_preserves_adr_and_sibling_project) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/full_replace.db", g_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char project[CBM_SZ_256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + + static const char adr_text[] = "# Decision\nPreserve user-authored context."; + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_adr_store(store, project, adr_text), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(store, "sibling", "/tmp/sibling"), CBM_STORE_OK); + cbm_node_t sibling = {.project = "sibling", + .label = "Function", + .name = "keep", + .qualified_name = "sibling.keep", + .file_path = "keep.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &sibling), 0); + cbm_store_close(store); + + /* The default policy disables incremental indexing. A second run must + * still replace only this project's derived graph, not rewrite the DB. */ + p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + cbm_adr_t adr = {0}; + ASSERT_EQ(cbm_store_adr_get(store, project, &adr), CBM_STORE_OK); + ASSERT_STR_EQ(adr.content, adr_text); + cbm_store_adr_free(&adr); + ASSERT_EQ(cbm_store_count_nodes(store, "sibling"), 1); + cbm_node_t kept = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, "sibling", "sibling.keep", &kept), CBM_STORE_OK); + cbm_node_free_fields(&kept); + cbm_store_close(store); + + teardown_test_repo(); + PASS(); +} + +/* Issue #516: an ADR stored via manage_adr must survive the delete-and-rebuild + * full-index route, not only the project-scoped replacement route above. */ TEST(pipeline_adr_survives_full_reindex) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_adr_XXXXXX"); @@ -290,8 +398,6 @@ TEST(pipeline_adr_survives_full_reindex) { char db_path[512]; snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); - - /* Initial index with a single source file. */ char path[512]; snprintf(path, sizeof(path), "%s/main.py", tmp); FILE *f = fopen(path, "w"); @@ -302,20 +408,16 @@ TEST(pipeline_adr_survives_full_reindex) { cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); ASSERT_NOT_NULL(p1); ASSERT_EQ(cbm_pipeline_run(p1), 0); - const char *project = cbm_pipeline_project_name(p1); - char project_copy[256]; - snprintf(project_copy, sizeof(project_copy), "%s", project); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1)); cbm_pipeline_free(p1); - /* Store an ADR. */ - const char *adr_text = "# Decision\nWe chose X over Y."; - cbm_store_t *s1 = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(s1); - ASSERT_EQ(cbm_store_adr_store(s1, project_copy, adr_text), CBM_STORE_OK); - cbm_store_close(s1); + static const char adr_text[] = "# Decision\nWe chose X over Y."; + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_adr_store(store, project, adr_text), CBM_STORE_OK); + cbm_store_close(store); - /* Force a full re-index: add enough files to exceed the incremental - * threshold so the DB is deleted and rebuilt. */ for (int i = 0; i < 4; i++) { snprintf(path, sizeof(path), "%s/extra%d.py", tmp, i); f = fopen(path, "w"); @@ -329,16 +431,14 @@ TEST(pipeline_adr_survives_full_reindex) { ASSERT_EQ(cbm_pipeline_run(p2), 0); cbm_pipeline_free(p2); - /* The ADR must still be present and unchanged. */ - cbm_store_t *s2 = cbm_store_open_path(db_path); - ASSERT_NOT_NULL(s2); + store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); cbm_adr_t adr = {0}; - int rc = cbm_store_adr_get(s2, project_copy, &adr); - ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(cbm_store_adr_get(store, project, &adr), CBM_STORE_OK); ASSERT_NOT_NULL(adr.content); ASSERT_STR_EQ(adr.content, adr_text); cbm_store_adr_free(&adr); - cbm_store_close(s2); + cbm_store_close(store); rm_rf(tmp); PASS(); @@ -364,15 +464,59 @@ TEST(pipeline_structure_edges) { /* Check CONTAINS_FILE edges */ int cf_count = cbm_store_count_edges_by_type(s, project, "CONTAINS_FILE"); /* Check CONTAINS_FOLDER edges */ - int cd_count = cbm_store_count_edges_by_type(s, project, "CONTAINS_FOLDER"); + int cd_count = cbm_store_count_edges_by_type(s, project, CBM_PIPELINE_EDGE_CONTAINS_FOLDER); + + char *pkg_qn = cbm_pipeline_fqn_folder(project, "pkg"); + char *util_qn = cbm_pipeline_fqn_folder(project, "pkg/util"); + bool made_pkg_qn = pkg_qn != NULL; + bool made_util_qn = util_qn != NULL; + cbm_node_t pkg_node = {0}; + cbm_node_t util_node = {0}; + bool found_pkg = false; + bool found_util = false; + if (pkg_qn) { + rc = cbm_store_find_node_by_qn(s, project, pkg_qn, &pkg_node); + found_pkg = rc == CBM_STORE_OK; + } + if (util_qn) { + rc = cbm_store_find_node_by_qn(s, project, util_qn, &util_node); + found_util = rc == CBM_STORE_OK; + } + cbm_edge_t *pkg_folders = NULL; + int pkg_folder_count = 0; + bool found_pkg_folder_edges = false; + if (found_pkg) { + rc = cbm_store_find_edges_by_source_type(s, pkg_node.id, + CBM_PIPELINE_EDGE_CONTAINS_FOLDER, &pkg_folders, + &pkg_folder_count); + found_pkg_folder_edges = rc == CBM_STORE_OK; + } + bool has_nested_folder_edge = false; + for (int i = 0; i < pkg_folder_count; i++) { + if (pkg_folders[i].target_id == util_node.id) { + has_nested_folder_edge = true; + break; + } + } /* Cleanup before assertions (so failures don't leak) */ + cbm_store_free_edges(pkg_folders, pkg_folder_count); + cbm_node_free_fields(&pkg_node); + cbm_node_free_fields(&util_node); + free(pkg_qn); + free(util_qn); cbm_store_close(s); cbm_pipeline_free(p); teardown_test_repo(); ASSERT_GTE(cf_count, 3); /* project->main.go, pkg->service.go, util->helper.go */ - ASSERT_GTE(cd_count, 1); /* project->pkg (pkg->util may merge on some platforms) */ + ASSERT_GTE(cd_count, 2); /* branch->pkg and pkg->util */ + ASSERT_TRUE(made_pkg_qn); + ASSERT_TRUE(made_util_qn); + ASSERT_TRUE(found_pkg); + ASSERT_TRUE(found_util); + ASSERT_TRUE(found_pkg_folder_edges); + ASSERT_TRUE(has_nested_folder_edge); PASS(); } @@ -481,6 +625,139 @@ TEST(pipeline_project_name_derived) { PASS(); } +TEST(pipeline_mode_global_semantic_edges_policy) { + ASSERT_TRUE(cbm_pipeline_mode_builds_global_semantic_edges(CBM_MODE_FULL)); + ASSERT_TRUE(cbm_pipeline_mode_builds_global_semantic_edges(CBM_MODE_MODERATE)); + ASSERT_FALSE(cbm_pipeline_mode_builds_global_semantic_edges(CBM_MODE_FAST)); + ASSERT_FALSE(cbm_pipeline_mode_builds_global_semantic_edges(CBM_MODE_DEP)); + PASS(); +} + +TEST(pipeline_call_edge_props_include_args_and_line) { + char props[CBM_SZ_512]; + int n = snprintf(props, sizeof(props), + "{\"callee\":\"cbm_label_is_type_like\",\"confidence\":0.75," + "\"strategy\":\"unique_name\",\"candidates\":1"); + ASSERT_GT(n, 0); + + CBMCall call = {0}; + call.start_line = 62; + call.arg_count = 1; + call.args[0].index = 0; + call.args[0].expr = "label"; + + cbm_pipeline_close_call_edge_props(props, sizeof(props), (size_t)n, &call, true); + ASSERT(strstr(props, "\"args\":[{\"i\":0,\"e\":\"label\"}]") != NULL); + ASSERT(strstr(props, "\"line\":62") != NULL); + ASSERT_EQ(props[strlen(props) - SKIP_ONE], '}'); + PASS(); +} + +TEST(pipeline_weak_call_target_suppression) { + cbm_gbuf_node_t function_target = {.label = "Function"}; + cbm_gbuf_node_t class_target = {.label = "Class"}; + cbm_gbuf_node_t variable_target = {.label = "Variable"}; + cbm_gbuf_node_t field_target = {.label = "Field"}; + + ASSERT_FALSE(cbm_pipeline_should_suppress_weak_noncallable_call_target( + &function_target, "unique_name")); + ASSERT_FALSE(cbm_pipeline_should_suppress_weak_noncallable_call_target( + &class_target, "suffix_match")); + ASSERT_TRUE(cbm_pipeline_should_suppress_weak_noncallable_call_target( + &variable_target, "unique_name")); + ASSERT_TRUE(cbm_pipeline_should_suppress_weak_noncallable_call_target( + &field_target, "suffix_match")); + ASSERT_FALSE(cbm_pipeline_should_suppress_weak_noncallable_call_target( + &variable_target, "same_module")); + ASSERT_FALSE(cbm_pipeline_should_suppress_weak_noncallable_call_target( + &variable_target, "import_map")); + ASSERT_FALSE(cbm_pipeline_should_suppress_weak_noncallable_call_target(NULL, "unique_name")); + PASS(); +} + +TEST(pipeline_member_call_normalization) { + CBMCall call = {.callee_name = "receiver.matches"}; + ASSERT_TRUE(cbm_pipeline_call_is_member(&call, CBM_LANG_RUST)); + ASSERT_FALSE(cbm_pipeline_call_is_member(&call, CBM_LANG_GO)); + + call.callee_name = "module::matches"; + ASSERT_FALSE(cbm_pipeline_call_is_member(&call, CBM_LANG_RUST)); + + call.is_method = true; + ASSERT_TRUE(cbm_pipeline_call_is_member(&call, CBM_LANG_TYPESCRIPT)); + ASSERT_FALSE(cbm_pipeline_call_is_member(NULL, CBM_LANG_RUST)); + PASS(); +} + +TEST(pipeline_sequential_call_edges_preserve_eighth_arg) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + const char *py_path = TH_PATH(g_tmpdir, "wide_args.py"); + ASSERT_EQ(th_write_file(py_path, + "def wide_target(**kwargs):\n" + " return kwargs\n" + "\n" + "def wide_caller():\n" + " first_expression_value = 1\n" + " second_expression_value = 2\n" + " third_expression_value = 3\n" + " fourth_expression_value = 4\n" + " fifth_expression_value = 5\n" + " sixth_expression_value = 6\n" + " seventh_expression_value = 7\n" + " eighth_expression_value = 8\n" + " return wide_target(\n" + " first_keyword_argument=first_expression_value,\n" + " second_keyword_argument=second_expression_value,\n" + " third_keyword_argument=third_expression_value,\n" + " fourth_keyword_argument=fourth_expression_value,\n" + " fifth_keyword_argument=fifth_expression_value,\n" + " sixth_keyword_argument=sixth_expression_value,\n" + " seventh_keyword_argument=seventh_expression_value,\n" + " eighth_keyword_argument=eighth_expression_value,\n" + " )\n"), + 0); + + char db_path[CBM_SZ_512]; + int n = snprintf(db_path, sizeof(db_path), "%s/wide_args.db", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db_path)); + + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + ASSERT_NOT_NULL(project); + + sqlite3 *db = NULL; + ASSERT_EQ(sqlite3_open(db_path, &db), SQLITE_OK); + sqlite3_stmt *stmt = NULL; + const char sql[] = + "SELECT e.properties FROM edges e " + "JOIN nodes t ON t.id = e.target_id " + "WHERE e.project = ?1 AND e.type = 'CALLS' AND t.name = 'wide_target' " + "LIMIT 1"; + ASSERT_EQ(sqlite3_prepare_v2(db, sql, -1, &stmt, NULL), SQLITE_OK); + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + const char *edge_props = (const char *)sqlite3_column_text(stmt, 0); + ASSERT_NOT_NULL(edge_props); + ASSERT(strstr(edge_props, "\"k\":\"eighth_keyword_argument\"") != NULL); + ASSERT(strstr(edge_props, "\"e\":\"eighth_expression_value\"") != NULL); + static const char args_key[] = "\"args\":"; + const char *first_args_key = strstr(edge_props, args_key); + ASSERT_NOT_NULL(first_args_key); + ASSERT(strstr(first_args_key + sizeof(args_key) - SKIP_ONE, args_key) == NULL); + + sqlite3_finalize(stmt); + sqlite3_close(db); + cbm_pipeline_free(p); + teardown_test_repo(); + PASS(); +} + TEST(pipeline_fast_mode) { if (setup_test_repo() != 0) { FAIL("failed to create temp dir"); @@ -501,6 +778,12 @@ TEST(pipeline_fast_mode) { const char *project = cbm_pipeline_project_name(p); int node_count = cbm_store_count_nodes(s, project); ASSERT_GT(node_count, 0); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_STATUS_COMPLETE)); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + CBM_STORE_DERIVED_STATUS_COMPLETE)); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_STATUS_STALE)); cbm_store_close(s); cbm_pipeline_free(p); @@ -786,6 +1069,142 @@ TEST(pipeline_edge_props_valid_json) { PASS(); } +TEST(pipeline_persisted_route_purity_for_http_literals) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/route_noise.py", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(path)); + FILE *f = fopen(path, "w"); + if (!f) { + teardown_test_repo(); + FAIL("failed to write route_noise.py"); + } + fprintf(f, "import os\n" + "from fastapi import FastAPI\n" + "import requests\n" + "\n" + "app = FastAPI()\n" + "\n" + "@app.get('/api/orders')\n" + "def orders():\n" + " return {'ok': True}\n" + "\n" + "def client():\n" + " requests.get('/tmp/alpha')\n" + " requests.get('/Users/test/plans/foo.md')\n" + " requests.get('/ar:allow')\n" + " os.path.join('/api', 'orders')\n" + " open('/usr/bin/uv')\n"); + fclose(f); + + char db_path[CBM_PATH_MAX]; + n = snprintf(db_path, sizeof(db_path), "%s/test_route_purity.db", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db_path)); + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + cbm_node_t *routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count), + CBM_STORE_OK); + bool saw_api = false; + for (int i = 0; i < route_count; i++) { + const char *name = routes[i].name ? routes[i].name : ""; + if (strcmp(name, "/api/orders") == 0) { + saw_api = true; + continue; + } + ASSERT_FALSE(strcmp(name, "/tmp/alpha") == 0); + ASSERT_FALSE(strcmp(name, "/Users/test/plans/foo.md") == 0); + ASSERT_FALSE(strcmp(name, "/ar:allow") == 0); + ASSERT_FALSE(strcmp(name, "/usr/bin/uv") == 0); + } + ASSERT_TRUE(saw_api); + + cbm_search_params_t params = { + .project = project, .label = "Route", .min_degree = -1, .max_degree = -1, .limit = 100}; + cbm_search_output_t out = {0}; + ASSERT_EQ(cbm_store_search(s, ¶ms, &out), CBM_STORE_OK); + for (int i = 0; i < out.count; i++) { + const char *name = out.results[i].node.name ? out.results[i].node.name : ""; + ASSERT_FALSE(strcmp(name, "/tmp/alpha") == 0); + ASSERT_FALSE(strcmp(name, "/Users/test/plans/foo.md") == 0); + ASSERT_FALSE(strcmp(name, "/ar:allow") == 0); + ASSERT_FALSE(strcmp(name, "/usr/bin/uv") == 0); + } + + cbm_store_search_free(&out); + cbm_store_free_nodes(routes, route_count); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_test_repo(); + PASS(); +} + +TEST(pipeline_infra_route_deny_wins_by_url_value) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/infra.yaml", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(path)); + FILE *f = fopen(path, "w"); + if (!f) { + teardown_test_repo(); + FAIL("failed to write infra.yaml"); + } + fprintf(f, "registries:\n" + " terraform_registry:\n" + " url: https://registry.terraform.io\n" + "healthcheck: curl --fail http://localhost:8080/health || exit 1\n" + "push_endpoint: https://hooks.example.test/push\n"); + fclose(f); + + char db_path[CBM_PATH_MAX]; + n = snprintf(db_path, sizeof(db_path), "%s/test_infra_route_deny.db", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db_path)); + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + cbm_node_t *routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Route", &routes, &route_count), + CBM_STORE_OK); + bool saw_push_endpoint = false; + for (int i = 0; i < route_count; i++) { + const char *name = routes[i].name ? routes[i].name : ""; + ASSERT_FALSE(strcmp(name, "https://registry.terraform.io") == 0); + ASSERT_FALSE(strcmp(name, "http://localhost:8080/health") == 0); + ASSERT_FALSE(strstr(name, "curl --fail http://localhost:8080/health") != NULL); + if (strcmp(name, "https://hooks.example.test/push") == 0) { + saw_push_endpoint = true; + } + } + ASSERT_TRUE(saw_push_endpoint); + + cbm_store_free_nodes(routes, route_count); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_test_repo(); + PASS(); +} + /* ── Calls pass tests ──────────────────────────────────────────── */ TEST(pipeline_calls_resolution) { @@ -817,8 +1236,9 @@ TEST(pipeline_calls_resolution) { /* True iff a CALLS edge exists from a node named src_name to a node named * tgt_name. Used to assert cross-file call resolution survives a reindex. */ -static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name, - const char *tgt_name) { +static bool cross_file_call_with_strategy_exists(cbm_store_t *s, const char *project, + const char *src_name, const char *tgt_name, + const char *strategy) { cbm_node_t *srcs = NULL; cbm_node_t *tgts = NULL; int sc = 0; @@ -832,7 +1252,9 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch cbm_store_find_edges_by_source_type(s, srcs[i].id, "CALLS", &edges, &ec); for (int j = 0; j < ec && !found; j++) { for (int k = 0; k < tc; k++) { - if (edges[j].target_id == tgts[k].id) { + if (edges[j].target_id == tgts[k].id && + (!strategy || + (edges[j].properties_json && strstr(edges[j].properties_json, strategy)))) { found = true; break; } @@ -851,6 +1273,13 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch return found; } +static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name, + const char *tgt_name) { + return cross_file_call_with_strategy_exists(s, project, src_name, tgt_name, NULL); +} + +static cbm_config_t *incremental_test_config(const char *cache_dir); + /* Nix attrpath qualification, end to end. A call inside a scoped binding must * source to the QUALIFIED definition. * @@ -942,9 +1371,12 @@ TEST(pipeline_incremental_preserves_cross_file_calls) { snprintf(helper, sizeof(helper), "%s/pkg/util/helper.go", g_tmpdir); ASSERT_EQ(th_append_file(helper, "\n// incremental regression marker\n"), 0); - /* 3. Re-run on the SAME db_path → auto-routes to incremental re-index. */ + /* 3. Re-run on the SAME db_path with incremental explicitly enabled. */ + cbm_config_t *cfg = incremental_test_config(g_tmpdir); + ASSERT_NOT_NULL(cfg); cbm_pipeline_t *p2 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); ASSERT_NOT_NULL(p2); + cbm_pipeline_apply_config(p2, cfg); ASSERT_EQ(cbm_pipeline_run(p2), 0); /* 4. The inbound cross-file CALLS edge must survive and the total CALLS @@ -959,6 +1391,104 @@ TEST(pipeline_incremental_preserves_cross_file_calls) { ASSERT_TRUE(cross_file_call_exists(s2, project2, "Serve", "Help")); cbm_store_close(s2); cbm_pipeline_free(p2); + cbm_config_close(cfg); + + teardown_test_repo(); + PASS(); +} + +TEST(pipeline_full_and_incremental_persist_file_state) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char db_path[512]; + int n = snprintf(db_path, sizeof(db_path), "%s/test_file_state.db", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db_path)); + + cbm_pipeline_t *p1 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + + const char *project1 = cbm_pipeline_project_name(p1); + cbm_store_t *s1 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s1); + cbm_file_state_t first = {0}; + ASSERT_EQ(cbm_store_get_file_state(s1, project1, "pkg/util/helper.go", &first), CBM_STORE_OK); + ASSERT_STR_EQ(first.language, "Go"); + ASSERT_EQ(first.generation, CBM_PIPELINE_COMPAT_GENERATION); + int64_t first_generation = first.generation; + ASSERT_NOT_NULL(first.content_hash); + char first_hash[CBM_SZ_32]; + n = snprintf(first_hash, sizeof(first_hash), "%s", first.content_hash); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(first_hash)); + cbm_store_file_state_free_fields(&first); + cbm_store_close(s1); + cbm_pipeline_free(p1); + + char helper[512]; + n = snprintf(helper, sizeof(helper), "%s/pkg/util/helper.go", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(helper)); + ASSERT_EQ(th_append_file(helper, "\nfunc Extra() {}\n"), 0); + + cbm_config_t *cfg = incremental_test_config(g_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p2 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + cbm_pipeline_apply_config(p2, cfg); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + + const char *project2 = cbm_pipeline_project_name(p2); + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_file_state_t second = {0}; + ASSERT_EQ(cbm_store_get_file_state(s2, project2, "pkg/util/helper.go", &second), + CBM_STORE_OK); + ASSERT_STR_EQ(second.language, "Go"); + ASSERT_GT(second.generation, first_generation); + ASSERT_NOT_NULL(second.content_hash); + ASSERT_NEQ(strcmp(first_hash, second.content_hash), 0); + cbm_store_file_state_free_fields(&second); + cbm_store_close(s2); + cbm_pipeline_free(p2); + cbm_config_close(cfg); + + teardown_test_repo(); + PASS(); +} + +TEST(pipeline_incremental_full_index_rebuilds_owner_metadata) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char db_path[512]; + int n = snprintf(db_path, sizeof(db_path), "%s/test_owner_metadata.db", g_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db_path)); + + cbm_config_t *cfg = incremental_test_config(g_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int node_owners = 0; + int edge_owners = 0; + ASSERT_EQ(cbm_store_count_file_delta_owners(s, project, "pkg/util/helper.go", + &node_owners, &edge_owners), + CBM_STORE_OK); + ASSERT_GT(node_owners, 0); + cbm_store_close(s); + cbm_pipeline_free(p); + cbm_config_close(cfg); teardown_test_repo(); PASS(); @@ -1036,6 +1566,92 @@ TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge) { PASS(); } +/* Rust also resolves typed receivers through its LSP before the generic + * registry. An unresolved member receiver must not fall back to an unrelated + * project method by weak suffix matching, while a typed receiver must retain + * its real lsp_method_dispatch CALLS edge. The unresolved receiver is intentionally a + * semantic error: extraction must remain conservative when type lookup fails. + * RED before the fix: + * check_unknown->matches exists via suffix_match. */ +static int pipeline_rust_receiver_suppression_case(bool force_parallel) { + enum { RUST_RECEIVER_PARALLEL_PAD_FILES = 52 }; + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rust_recv_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return 0; + } + + write_temp_file(tmp, "src/query.rs", + "pub struct CompiledProcessQuery;\n" + "impl CompiledProcessQuery {\n" + " pub fn matches(&self) -> bool { true }\n" + "}\n" + "pub struct OtherQuery;\n" + "impl OtherQuery {\n" + " pub fn matches(&self) -> bool { false }\n" + "}\n" + "pub fn check_typed(query: &CompiledProcessQuery) -> bool {\n" + " query.matches()\n" + "}\n"); + write_temp_file(tmp, "src/lib.rs", + "mod query;\n" + "mod unknown;\n"); + write_temp_file(tmp, "src/unknown.rs", + "pub fn check_unknown(value: UnknownReceiver) -> bool {\n" + " value.matches()\n" + "}\n" + "pub fn check_macro(value: bool) -> bool {\n" + " matches!(value, true)\n" + "}\n"); + if (force_parallel) { + for (int i = 0; i < RUST_RECEIVER_PARALLEL_PAD_FILES; i++) { + char name[CBM_SZ_64]; + char body[CBM_SZ_128]; + snprintf(name, sizeof(name), "src/pad_%02d.rs", i); + snprintf(body, sizeof(body), "pub fn pad_%02d() -> i32 { %d }\n", i, i); + write_temp_file(tmp, name, body); + } + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/rust_recv.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + cbm_store_t *s = NULL; + int ok = p && cbm_pipeline_run(p) == 0; + const char *project = ok ? cbm_pipeline_project_name(p) : NULL; + if (ok) { + s = cbm_store_open_path(db_path); + bool unknown_edge = s && cross_file_call_exists(s, project, "check_unknown", "matches"); + bool macro_edge = s && cross_file_call_exists(s, project, "check_macro", "matches"); + bool typed_edge = s && cross_file_call_with_strategy_exists( + s, project, "check_typed", "matches", "lsp_method_dispatch"); + ok = s && !unknown_edge && !macro_edge && typed_edge; + if (!ok) { + fprintf(stderr, + " [RUST-RECEIVER] parallel=%d unknown_edge=%d macro_edge=%d typed_edge=%d " + "db=%s\n", + force_parallel, unknown_edge, macro_edge, typed_edge, db_path); + } + } + + cbm_store_close(s); + cbm_pipeline_free(p); + if (ok) { + th_rmtree(tmp); + } + return ok; +} + +TEST(pipeline_rust_receiver_suppresses_weak_method_edge) { + ASSERT_TRUE(pipeline_rust_receiver_suppression_case(false)); + PASS(); +} + +TEST(pipeline_rust_receiver_parallel_suppresses_weak_method_edge) { + ASSERT_TRUE(pipeline_rust_receiver_suppression_case(true)); + PASS(); +} + /* Count nodes with the given exact name in the project (e.g. a Route path). */ static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) { cbm_node_t *ns = NULL; @@ -1047,26 +1663,382 @@ static int count_nodes_named(cbm_store_t *s, const char *project, const char *na return n; } -/* Parallel-resolver regression for the TS/JS receiver guard (>= 50 files forces - * pass_parallel.c's resolve_file_calls). The guard must not drop a weak member - * match before the service classification runs — it suppresses ONLY the plain - * CALLS fall-through, so every service edge (HTTP_CALLS via the #523 callee - * bypass or emit_service_edge's unconditional detect_url_in_args, Route via the - * ROUTE_REG fall-through, …) is emitted exactly as on main. These callees are - * classified by main's verb-suffix + URL-arg heuristic, NOT by an HTTP library - * name in the callee — a duplicated predicate keyed on the resolved QN lost them - * (axios.get, api.patch on a renamed-axios instance, supertest request(app).get). - * The regex false edge must stay suppressed in parallel too. CBM_WORKERS forces - * >1 worker so the parallel path is taken regardless of the host core count. */ -TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { - char tmp[256]; - snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX"); +/* Source-based route discovery is a fallback for frameworks whose call path + * cannot yet produce a canonical service Route (notably handlerless Ktor + * blocks). When AST resolution already emitted the endpoint, the httplink + * rescan must reuse that identity rather than mint handler-qualified Route + * clones. Function and Module scans must also converge on the same endpoint. + * This is an exact graph contract: Route nodes are cross-service rendezvous + * points, so duplicate identities split traversal results rather than merely + * consuming extra space. */ +static int pipeline_route_discovery_uses_canonical_identities_case(bool force_parallel) { + static const char *const expected_paths[] = {"/go/orders", "/ts/users", "/kt/status"}; + static const char *const expected_qns[] = {"__route__GET__/go/orders", + "__route__GET__/ts/users", + "__route__GET__/kt/status"}; + enum { + ROUTE_IDENTITY_EXPECTED_COUNT = sizeof(expected_paths) / sizeof(expected_paths[0]), + ROUTE_IDENTITY_PARALLEL_PAD_FILES = 52, + }; + + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_route_identity_XXXXXX"); if (!cbm_mkdtemp(tmp)) { - FAIL("tmpdir"); + return 0; } - /* The lone project symbols named get()/patch()/test() — weak short-name - * targets the registry mis-binds the member calls below to. */ + write_temp_file(tmp, "routes.go", + "package routes\n\n" + "type Engine struct{}\n" + "func (e *Engine) GET(path string, handler interface{}) {}\n" + "func listOrders() {}\n" + "func RegisterRoutes(r *Engine) {\n" + "\tr.GET(\"/go/orders\", listOrders)\n" + "}\n"); + write_temp_file(tmp, "routes.ts", + "function listUsers(): void {}\n" + "export function registerRoutes(app: any): void {\n" + " app.get('/ts/users', listUsers);\n" + "}\n"); + write_temp_file(tmp, "Routes.kt", + "package routes\n\n" + "fun configureRoutes() {\n" + " routing {\n" + " get(\"/kt/status\") { }\n" + " }\n" + "}\n"); + + if (force_parallel) { + for (int i = 0; i < ROUTE_IDENTITY_PARALLEL_PAD_FILES; i++) { + char name[CBM_SZ_64]; + char body[CBM_SZ_128]; + snprintf(name, sizeof(name), "pad_%02d.py", i); + snprintf(body, sizeof(body), "def pad_%02d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + } + + char db_path[CBM_PATH_MAX]; + int n = snprintf(db_path, sizeof(db_path), "%s/routes.db", tmp); + int ok = n > 0 && (size_t)n < sizeof(db_path); + cbm_pipeline_t *pipeline = ok ? cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL) : NULL; + cbm_store_t *store = NULL; + cbm_node_t *routes = NULL; + int route_count = 0; + if (!pipeline || cbm_pipeline_run(pipeline) != 0) { + ok = 0; + goto cleanup; + } + + store = cbm_store_open_path(db_path); + const char *project = cbm_pipeline_project_name(pipeline); + if (!store || cbm_store_find_nodes_by_label(store, project, "Route", &routes, &route_count) != + CBM_STORE_OK || + route_count != ROUTE_IDENTITY_EXPECTED_COUNT) { + ok = 0; + goto cleanup; + } + + for (int i = 0; i < ROUTE_IDENTITY_EXPECTED_COUNT; i++) { + cbm_node_t route = {0}; + if (count_nodes_named(store, project, expected_paths[i]) != 1 || + cbm_store_find_node_by_qn(store, project, expected_qns[i], &route) != CBM_STORE_OK) { + ok = 0; + } + cbm_node_free_fields(&route); + } + if (cbm_store_count_edges_by_type(store, project, "HANDLES") != + ROUTE_IDENTITY_EXPECTED_COUNT) { + ok = 0; + } + +cleanup: + if (!ok && store) { + fprintf(stderr, " [ROUTE-IDENTITY] routes=%d handles=%d\n", route_count, + cbm_store_count_edges_by_type(store, project, "HANDLES")); + for (int i = 0; i < route_count; i++) { + fprintf(stderr, " route name=%s qn=%s props=%s\n", + routes[i].name ? routes[i].name : "", + routes[i].qualified_name ? routes[i].qualified_name : "", + routes[i].properties_json ? routes[i].properties_json : ""); + } + cbm_edge_t *http_edges = NULL; + int http_edge_count = 0; + if (cbm_store_find_edges_by_type(store, project, "HTTP_CALLS", &http_edges, + &http_edge_count) == CBM_STORE_OK) { + for (int i = 0; i < http_edge_count; i++) { + cbm_node_t target = {0}; + (void)cbm_store_find_node_by_id(store, http_edges[i].target_id, &target); + fprintf(stderr, " HTTP_CALLS target=%s props=%s\n", + target.qualified_name ? target.qualified_name : "", + http_edges[i].properties_json ? http_edges[i].properties_json : ""); + cbm_node_free_fields(&target); + } + } + cbm_store_free_edges(http_edges, http_edge_count); + } + cbm_store_free_nodes(routes, route_count); + cbm_store_close(store); + cbm_pipeline_free(pipeline); + th_rmtree(tmp); + return ok; +} + +/* A caller whose HTTP call uses a FULL URL literal must join the same + * canonical Route node the handler HANDLES. Previously route minting used the + * raw url_path, so "http://users-svc:5000/api/users" minted a second Route + * distinct from the registration's "/api/users": caller→Route and + * handler→Route never met and the cross-service join query returned nothing. */ +static int pipeline_full_url_call_joins_canonical_route_case(void) { + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_url_route_join_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return 0; + } + + write_temp_file(tmp, "service/app.py", + "from flask import Flask, jsonify\n\n" + "app = Flask(__name__)\n\n\n" + "@app.route(\"/api/users\", methods=[\"GET\"])\n" + "def get_users():\n" + " return jsonify([])\n"); + write_temp_file(tmp, "client/consumer.py", + "import requests\n\n\n" + "def fetch_users():\n" + " return requests.get(\"http://users-svc:5000/api/users\").json()\n"); + + char db_path[CBM_PATH_MAX]; + int n = snprintf(db_path, sizeof(db_path), "%s/urljoin.db", tmp); + int ok = n > 0 && (size_t)n < sizeof(db_path); + cbm_pipeline_t *pipeline = ok ? cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL) : NULL; + cbm_store_t *store = NULL; + cbm_node_t *routes = NULL; + int route_count = 0; + cbm_edge_t *http_edges = NULL; + int http_count = 0; + cbm_edge_t *handles_edges = NULL; + int handles_count = 0; + const char *project = NULL; + if (!pipeline || cbm_pipeline_run(pipeline) != 0) { + ok = 0; + goto cleanup; + } + + store = cbm_store_open_path(db_path); + project = cbm_pipeline_project_name(pipeline); + if (!store) { + ok = 0; + goto cleanup; + } + + /* No Route node may embed a scheme/authority. */ + if (cbm_store_find_nodes_by_label(store, project, "Route", &routes, &route_count) != + CBM_STORE_OK || + route_count < 1) { + ok = 0; + goto cleanup; + } + for (int i = 0; i < route_count; i++) { + if (routes[i].name && strstr(routes[i].name, "://")) { + ok = 0; + } + } + + /* The join: at least one HTTP_CALLS edge must target the same Route node + * that a HANDLES edge targets, and that Route must be "/api/users". */ + if (cbm_store_find_edges_by_type(store, project, "HTTP_CALLS", &http_edges, &http_count) != + CBM_STORE_OK || + cbm_store_find_edges_by_type(store, project, "HANDLES", &handles_edges, &handles_count) != + CBM_STORE_OK) { + ok = 0; + goto cleanup; + } + bool joined = false; + for (int i = 0; i < http_count && !joined; i++) { + for (int j = 0; j < handles_count && !joined; j++) { + if (http_edges[i].target_id != handles_edges[j].target_id) { + continue; + } + cbm_node_t route = {0}; + if (cbm_store_find_node_by_id(store, http_edges[i].target_id, &route) == + CBM_STORE_OK && + route.name && strcmp(route.name, "/api/users") == 0) { + joined = true; + } + cbm_node_free_fields(&route); + } + } + if (!joined) { + ok = 0; + } + +cleanup: + if (!ok && store) { + fprintf(stderr, " [URL-JOIN] routes=%d http=%d handles=%d\n", route_count, http_count, + handles_count); + for (int i = 0; i < route_count; i++) { + fprintf(stderr, " route name=%s qn=%s\n", routes[i].name ? routes[i].name : "", + routes[i].qualified_name ? routes[i].qualified_name : ""); + } + } + cbm_store_free_edges(http_edges, http_count); + cbm_store_free_edges(handles_edges, handles_count); + cbm_store_free_nodes(routes, route_count); + cbm_store_close(store); + cbm_pipeline_free(pipeline); + th_rmtree(tmp); + return ok; +} + +TEST(pipeline_full_url_call_joins_canonical_route) { + ASSERT_TRUE(pipeline_full_url_call_joins_canonical_route_case()); + PASS(); +} + +TEST(pipeline_route_discovery_uses_canonical_identities_sequential) { + ASSERT_TRUE(pipeline_route_discovery_uses_canonical_identities_case(false)); + PASS(); +} + +TEST(pipeline_route_discovery_uses_canonical_identities_parallel) { + ASSERT_TRUE(pipeline_route_discovery_uses_canonical_identities_case(true)); + PASS(); +} + +/* Route and call-site discovery must not silently stop at a per-worker or + * pass-wide collection ceiling. Keep this as a direct httplink-pass test so + * AST registration cannot mask source-discovery loss. */ +static bool pipeline_httplink_collects_all_large_fixture(void) { + enum { ROUTE_COUNT = 600 }; + char *repo = th_mktempdir("cbm_httplink_scale"); + if (!repo) { + return false; + } + + char source_path[CBM_PATH_MAX]; + int path_len = snprintf(source_path, sizeof(source_path), "%s/routes.ts", repo); + char saved_workers[CBM_SZ_64]; + bool had_workers = cbm_safe_getenv("CBM_WORKERS", saved_workers, sizeof(saved_workers), NULL); + FILE *source = NULL; + cbm_gbuf_t *gbuf = NULL; + cbm_registry_t *registry = NULL; + bool ok = false; + if (path_len < 0 || (size_t)path_len >= sizeof(source_path)) { + goto cleanup; + } + source = fopen(source_path, "w"); + if (!source) { + goto cleanup; + } + for (int route_index = 0; route_index < ROUTE_COUNT; route_index++) { + if (fprintf(source, "app.get('/route-%03d', handler);\n", route_index) < 0) { + goto cleanup; + } + } + int caller_start_line = ROUTE_COUNT + 1; + if (fprintf(source, "function callAll() {\n") < 0) { + goto cleanup; + } + for (int route_index = 0; route_index < ROUTE_COUNT; route_index++) { + if (fprintf(source, " fetch('/route-%03d');\n", route_index) < 0) { + goto cleanup; + } + } + if (fprintf(source, "}\n") < 0 || fclose(source) != 0) { + source = NULL; + goto cleanup; + } + source = NULL; + + gbuf = cbm_gbuf_new("httplink-scale", repo); + registry = cbm_registry_new(); + if (!gbuf || !registry) { + goto cleanup; + } + for (int route_index = 0; route_index < ROUTE_COUNT; route_index++) { + char handler_name[CBM_SZ_64]; + char handler_qn[CBM_SZ_256]; + snprintf(handler_name, sizeof(handler_name), "handler_%03d", route_index); + snprintf(handler_qn, sizeof(handler_qn), "httplink-scale.service-%03d.routes.%s", + route_index, handler_name); + if (cbm_gbuf_upsert_node(gbuf, "Function", handler_name, handler_qn, "routes.ts", + route_index + 1, route_index + 1, "{}") <= 0) { + goto cleanup; + } + } + int64_t caller_id = cbm_gbuf_upsert_node( + gbuf, "Function", "callAll", "httplink-scale.client.calls.callAll", "routes.ts", + caller_start_line, 2 * ROUTE_COUNT + 2, "{}"); + if (caller_id <= 0 || cbm_setenv("CBM_WORKERS", "1", 1) != 0) { + goto cleanup; + } + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "httplink-scale", + .repo_path = repo, + .gbuf = gbuf, + .registry = registry, + .cancelled = &cancelled}; + if (cbm_pipeline_pass_httplinks(&ctx) != 0) { + goto cleanup; + } + + const cbm_gbuf_node_t **routes = NULL; + const cbm_gbuf_edge_t **http_calls = NULL; + int route_count = 0; + int http_call_count = 0; + if (cbm_gbuf_find_by_label(gbuf, "Route", &routes, &route_count) != 0 || + cbm_gbuf_find_edges_by_source_type(gbuf, caller_id, "HTTP_CALLS", &http_calls, + &http_call_count) != 0) { + goto cleanup; + } + ok = route_count == ROUTE_COUNT && http_call_count == ROUTE_COUNT; + if (!ok) { + printf(" httplink scale mismatch: routes=%d http_calls=%d expected=%d\n", route_count, + http_call_count, ROUTE_COUNT); + } + +cleanup: + if (had_workers) { + (void)cbm_setenv("CBM_WORKERS", saved_workers, 1); + } else { + (void)cbm_unsetenv("CBM_WORKERS"); + } + if (source) { + (void)fclose(source); + } + cbm_registry_free(registry); + cbm_gbuf_free(gbuf); + th_cleanup(repo); + return ok; +} + +TEST(pipeline_httplink_collection_has_no_fixed_item_ceiling) { + ASSERT_TRUE(pipeline_httplink_collects_all_large_fixture()); + PASS(); +} + +/* Parallel-resolver regression for the TS/JS receiver guard (>= 50 files forces + * pass_parallel.c's resolve_file_calls). The guard must not drop a weak member + * match before the service classification runs — it suppresses ONLY the plain + * CALLS fall-through, so every service edge (HTTP_CALLS via the #523 callee + * bypass or emit_service_edge's unconditional detect_url_in_args, Route via the + * ROUTE_REG fall-through, …) is emitted exactly as on main. These callees are + * classified by main's verb-suffix + URL-arg heuristic, NOT by an HTTP library + * name in the callee — a duplicated predicate keyed on the resolved QN lost them + * (axios.get, api.patch on a renamed-axios instance, supertest request(app).get). + * The regex false edge must stay suppressed in parallel too. CBM_WORKERS forces + * >1 worker so the parallel path is taken regardless of the host core count. */ +TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* The lone project symbols named get()/patch()/test() — weak short-name + * targets the registry mis-binds the member calls below to. */ write_temp_file(tmp, "src/thing.ts", "export class ApiThing {\n" " get(): number {\n" @@ -1159,11 +2131,10 @@ TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { * a route suffix and `dev` is not an HTTP lib, so it was dropped before * emit_service_edge ran (RED on that guard: only axios's 2). */ ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 3); - /* (2) The verb-suffix + route-path member calls keep their route - * registrations (edge type CALLS -> a Route node named by the path). These - * classify as route_registration on main, NOT HTTP_CALLS — Option A preserves - * that by construction. Assert the three Route paths survive: - * api.patch('/plans/:id'), request(app).get('/y'), router.get('/users'). */ + /* (2) Verb-suffix calls keep their exact endpoint nodes. Generic + * handlerless clients such as api.patch/request(app).get classify through + * URL-argument HTTP detection, while router.get with a handler remains a + * route registration (commit a0a320f5). */ ASSERT_GTE(count_nodes_named(s, project, "/plans/:id"), 1); ASSERT_GTE(count_nodes_named(s, project, "/y"), 1); ASSERT_GTE(count_nodes_named(s, project, "/users"), 1); @@ -1382,6 +2353,7 @@ TEST(githistory_compute_coupling) { strcmp(results[i].file_b, "d.go") == 0); } + cbm_change_coupling_paths_free(results, n); PASS(); } @@ -1422,6 +2394,180 @@ TEST(githistory_coupling_carries_last_co_change) { found_ab = true; } ASSERT_TRUE(found_ab); + cbm_change_coupling_paths_free(results, n); + PASS(); +} + +TEST(githistory_coupling_ranks_bounded_output_and_reports_omissions) { + char *weak[] = {"weak-a.go", "weak-b.go"}; + char *medium[] = {"medium-a.go", "medium-b.go"}; + char *strong[] = {"strong-a.go", "strong-b.go"}; + char *noisy[] = {"noisy-a.go", "noisy-b.go"}; + char *noisy_a[] = {"noisy-a.go"}; + char *noisy_b[] = {"noisy-b.go"}; + cbm_commit_files_t commits[] = { + {weak, 2, 101}, {medium, 2, 201}, {strong, 2, 301}, {weak, 2, 102}, + {medium, 2, 202}, {strong, 2, 302}, {weak, 2, 103}, {medium, 2, 203}, + {strong, 2, 303}, {medium, 2, 204}, {strong, 2, 304}, {strong, 2, 305}, + {noisy, 2, 401}, {noisy, 2, 402}, {noisy, 2, 403}, {noisy, 2, 404}, + {noisy, 2, 405}, {noisy, 2, 406}, {noisy_a, 1, 407}, {noisy_a, 1, 408}, + {noisy_a, 1, 409}, {noisy_a, 1, 410}, {noisy_b, 1, 411}, {noisy_b, 1, 412}, + {noisy_b, 1, 413}, {noisy_b, 1, 414}, + }; + int commit_count = (int)(sizeof(commits) / sizeof(*commits)); + cbm_change_coupling_t out[2]; + cbm_change_coupling_result_t result = cbm_compute_change_coupling_result( + commits, commit_count, out, (int)(sizeof(out) / sizeof(*out)), 0.0); + + ASSERT_EQ(result.written, 2); + ASSERT_EQ(result.eligible, 4); + ASSERT_EQ(result.omitted, 2); + ASSERT_EQ(result.path_too_long, 0); + ASSERT_EQ(result.allocation_failed, 0); + ASSERT_STR_EQ(out[0].file_a, "strong-a.go"); + ASSERT_STR_EQ(out[0].file_b, "strong-b.go"); + ASSERT_EQ(out[0].co_change_count, 5); + ASSERT_STR_EQ(out[1].file_a, "medium-a.go"); + ASSERT_STR_EQ(out[1].file_b, "medium-b.go"); + ASSERT_EQ(out[1].co_change_count, 4); + + cbm_change_coupling_t sentinel = {.co_change_count = 777}; + result = cbm_compute_change_coupling_result(commits, commit_count, &sentinel, 0, 0.0); + ASSERT_EQ(result.written, 0); + ASSERT_EQ(result.eligible, 4); + ASSERT_EQ(result.omitted, 4); + ASSERT_EQ(result.allocation_failed, 0); + ASSERT_EQ(sentinel.co_change_count, 777); + + cbm_commit_files_t reversed[sizeof(commits) / sizeof(*commits)]; + for (int i = 0; i < commit_count; i++) { + reversed[i] = commits[commit_count - i - 1]; + } + cbm_change_coupling_t reversed_out[2]; + result = cbm_compute_change_coupling_result(reversed, commit_count, reversed_out, + (int)(sizeof(reversed_out) / sizeof(*reversed_out)), + 0.0); + ASSERT_EQ(result.written, 2); + ASSERT_STR_EQ(reversed_out[0].file_a, out[0].file_a); + ASSERT_STR_EQ(reversed_out[0].file_b, out[0].file_b); + ASSERT_STR_EQ(reversed_out[1].file_a, out[1].file_a); + ASSERT_STR_EQ(reversed_out[1].file_b, out[1].file_b); + cbm_change_coupling_paths_free(reversed_out, result.written); + cbm_change_coupling_paths_free(out, 2); + + char long_a[CBM_SZ_1K]; + char long_b[CBM_SZ_1K]; + memset(long_a, 'a', sizeof(long_a)); + memset(long_b, 'b', sizeof(long_b)); + long_a[sizeof(long_a) - 1] = '\0'; + long_b[sizeof(long_b) - 1] = '\0'; + char *long_files[] = {long_a, long_b}; + cbm_commit_files_t long_commits[] = { + {long_files, 2, 1}, + {long_files, 2, 2}, + {long_files, 2, 3}, + }; + cbm_change_coupling_t long_out = {0}; + result = cbm_compute_change_coupling_result( + long_commits, (int)(sizeof(long_commits) / sizeof(*long_commits)), &long_out, 1, 0.0); + ASSERT_EQ(result.written, 1); + ASSERT_EQ(result.eligible, 1); + ASSERT_EQ(result.omitted, 0); + ASSERT_EQ(result.path_too_long, 0); + ASSERT_EQ(result.allocation_failed, 0); + ASSERT_STR_EQ(long_out.file_a, long_a); + ASSERT_STR_EQ(long_out.file_b, long_b); + ASSERT_EQ(long_out.co_change_count, 3); + cbm_change_coupling_paths_free(&long_out, result.written); + ASSERT_TRUE(long_out.file_a == NULL); + ASSERT_TRUE(long_out.file_b == NULL); + cbm_change_coupling_paths_free(&long_out, result.written); + PASS(); +} + +TEST(githistory_temporal_retains_files_past_legacy_capacity) { + enum { + GH_TEST_FILES_PER_COMMIT = 20, + GH_TEST_UNIQUE_FILES = 16385, + GH_TEST_BASE_COMMITS = + (GH_TEST_UNIQUE_FILES + GH_TEST_FILES_PER_COMMIT - 1) / GH_TEST_FILES_PER_COMMIT, + GH_TEST_COMMIT_COUNT = GH_TEST_BASE_COMMITS + 1, + }; + char (*paths)[CBM_SZ_32] = calloc(GH_TEST_UNIQUE_FILES, sizeof(*paths)); + char **file_ptrs = calloc(GH_TEST_UNIQUE_FILES, sizeof(*file_ptrs)); + cbm_commit_files_t *commits = calloc(GH_TEST_COMMIT_COUNT, sizeof(*commits)); + ASSERT_NOT_NULL(paths); + ASSERT_NOT_NULL(file_ptrs); + ASSERT_NOT_NULL(commits); + + for (int i = 0; i < GH_TEST_UNIQUE_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "file-%05d.go", i); + file_ptrs[i] = paths[i]; + } + for (int c = 0; c < GH_TEST_BASE_COMMITS; c++) { + int offset = c * GH_TEST_FILES_PER_COMMIT; + int remaining = GH_TEST_UNIQUE_FILES - offset; + commits[c].files = &file_ptrs[offset]; + commits[c].count = + remaining < GH_TEST_FILES_PER_COMMIT ? remaining : GH_TEST_FILES_PER_COMMIT; + commits[c].timestamp = c + 1; + } + commits[GH_TEST_BASE_COMMITS] = (cbm_commit_files_t){ + .files = &file_ptrs[GH_TEST_UNIQUE_FILES - 1], + .count = 1, + .timestamp = 999999, + }; + + cbm_file_temporal_t *temporal = NULL; + int temporal_count = 0; + ASSERT_EQ(cbm_compute_file_temporal(commits, GH_TEST_COMMIT_COUNT, &temporal, &temporal_count), + 0); + ASSERT_EQ(temporal_count, GH_TEST_UNIQUE_FILES); + + const cbm_file_temporal_t *last = NULL; + for (int i = 0; i < temporal_count; i++) { + if (strcmp(temporal[i].file_path, paths[GH_TEST_UNIQUE_FILES - 1]) == 0) { + last = &temporal[i]; + break; + } + } + ASSERT_NOT_NULL(last); + ASSERT_EQ(last->change_count, 2); + ASSERT_EQ(last->last_modified, 999999); + + cbm_file_temporal_free(temporal, temporal_count); + free(commits); + free(file_ptrs); + free(paths); + PASS(); +} + +TEST(githistory_temporal_preserves_long_file_paths) { + char path[CBM_SZ_1K]; + memset(path, 'a', sizeof(path)); + path[0] = 's'; + path[1] = 'r'; + path[2] = 'c'; + path[3] = '/'; + path[sizeof(path) - 4] = '.'; + path[sizeof(path) - 3] = 'c'; + path[sizeof(path) - 2] = 'p'; + path[sizeof(path) - 1] = '\0'; + + char *files[] = {path}; + cbm_commit_files_t commits[] = { + {.files = files, .count = 1, .timestamp = 123456}, + }; + cbm_file_temporal_t *temporal = NULL; + int temporal_count = 0; + + ASSERT_EQ(cbm_compute_file_temporal(commits, 1, &temporal, &temporal_count), 0); + ASSERT_EQ(temporal_count, 1); + ASSERT_STR_EQ(temporal[0].file_path, path); + ASSERT_EQ(temporal[0].change_count, 1); + ASSERT_EQ(temporal[0].last_modified, 123456); + + cbm_file_temporal_free(temporal, temporal_count); PASS(); } @@ -1477,6 +2623,7 @@ TEST(githistory_limits_to_max) { ASSERT_TRUE(n <= 100); /* Cleanup */ + cbm_change_coupling_paths_free(results, n); for (int i = 0; i < ncommits; i++) { free(commits[i].files); } @@ -1624,6 +2771,56 @@ TEST(implements_creates_override) { PASS(); } +TEST(implements_accepts_struct_label) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + int64_t iface_id = + cbm_gbuf_upsert_node(gb, "Interface", "Runner", "pkg.Runner", "pkg/runner.go", 1, 3, "{}"); + ASSERT_GT(iface_id, 0); + int64_t run_method_id = + cbm_gbuf_upsert_node(gb, "Method", "Run", "pkg.Runner.Run", "pkg/runner.go", 2, 2, "{}"); + ASSERT_GT(run_method_id, 0); + cbm_gbuf_insert_edge(gb, iface_id, run_method_id, "DEFINES_METHOD", "{}"); + + int64_t struct_id = + cbm_gbuf_upsert_node(gb, "Struct", "Job", "pkg.Job", "pkg/job.go", 1, 4, "{}"); + ASSERT_GT(struct_id, 0); + int64_t job_run_id = cbm_gbuf_upsert_node(gb, "Method", "Run", "pkg.Job.Run", "pkg/job.go", 2, + 3, "{\"receiver\":\"(j Job)\"}"); + ASSERT_GT(job_run_id, 0); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", + .repo_path = "/tmp/test", + .gbuf = gb, + .registry = NULL, + .cancelled = &cancelled, + }; + int edges_created = cbm_pipeline_implements_go(&ctx); + ASSERT_GT(edges_created, 0); + + const cbm_gbuf_edge_t **impl_edges = NULL; + int impl_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, struct_id, "IMPLEMENTS", &impl_edges, + &impl_count), + 0); + ASSERT_EQ(impl_count, 1); + ASSERT_EQ(impl_edges[0]->target_id, iface_id); + + const cbm_gbuf_edge_t **override_edges = NULL; + int override_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, job_run_id, "OVERRIDE", &override_edges, + &override_count), + 0); + ASSERT_EQ(override_count, 1); + ASSERT_EQ(override_edges[0]->target_id, run_method_id); + + cbm_gbuf_free(gb); + PASS(); +} + TEST(implements_no_match) { /* Port of TestPassImplementsNoOverrideWithoutMatch. * Interface requires Read+Write, struct only has Read → no edges. */ @@ -2030,26 +3227,46 @@ TEST(usages_kotlin_no_duplicate_calls) { static char g_lang_tmpdir[256]; static int setup_lang_repo(const char **filenames, const char **contents, int count) { - snprintf(g_lang_tmpdir, sizeof(g_lang_tmpdir), "/tmp/cbm_lang_XXXXXX"); - if (!cbm_mkdtemp(g_lang_tmpdir)) + const char *cache = cbm_resolve_cache_dir(); + int n = snprintf(g_lang_tmpdir, sizeof(g_lang_tmpdir), "%s/cbm-lang-XXXXXX", cache); + if (n < 0 || (size_t)n >= sizeof(g_lang_tmpdir) || !cbm_mkdtemp(g_lang_tmpdir)) { + g_lang_tmpdir[0] = '\0'; return -1; + } for (int i = 0; i < count; i++) { char path[512]; - snprintf(path, sizeof(path), "%s/%s", g_lang_tmpdir, filenames[i]); + n = snprintf(path, sizeof(path), "%s/%s", g_lang_tmpdir, filenames[i]); + if (n < 0 || (size_t)n >= sizeof(path)) { + rm_rf(g_lang_tmpdir); + g_lang_tmpdir[0] = '\0'; + return -1; + } /* Create parent directories */ char dir[512]; - snprintf(dir, sizeof(dir), "%s", path); + n = snprintf(dir, sizeof(dir), "%s", path); + if (n < 0 || (size_t)n >= sizeof(dir)) { + rm_rf(g_lang_tmpdir); + g_lang_tmpdir[0] = '\0'; + return -1; + } char *slash = strrchr(dir, '/'); if (slash) { *slash = '\0'; - th_mkdir_p(dir); + if (th_mkdir_p(dir) != 0) { + rm_rf(g_lang_tmpdir); + g_lang_tmpdir[0] = '\0'; + return -1; + } } FILE *f = fopen(path, "wb"); - if (!f) + if (!f) { + rm_rf(g_lang_tmpdir); + g_lang_tmpdir[0] = '\0'; return -1; + } fprintf(f, "%s", contents[i]); fclose(f); } @@ -2062,6 +3279,142 @@ static void teardown_lang_repo(void) { g_lang_tmpdir[0] = '\0'; } +static int pipeline_dump_store_file_to_file(const char *src_path, const char *dest_path) { + cbm_store_t *s = cbm_store_open_path(src_path); + if (!s) { + return CBM_STORE_ERR; + } + int rc = cbm_store_dump_to_file(s, dest_path); + cbm_store_close(s); + return rc; +} + +static bool pipeline_store_edge_between_qns_matches(const char *db_path, const char *project, + const char *source_qn, const char *type, + const char *target_qn, + const char *props_needle) { + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + return false; + } + + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + bool found = false; + if (cbm_store_find_node_by_qn(s, project, source_qn, &src) == CBM_STORE_OK && + cbm_store_find_node_by_qn(s, project, target_qn, &tgt) == CBM_STORE_OK) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_source_type(s, src.id, type, &edges, &edge_count) == + CBM_STORE_OK) { + for (int i = 0; i < edge_count; i++) { + if (edges[i].target_id == tgt.id && + (!props_needle || (edges[i].properties_json && + strstr(edges[i].properties_json, props_needle)))) { + found = true; + break; + } + } + cbm_store_free_edges(edges, edge_count); + } + } + + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + cbm_store_close(s); + return found; +} + +static bool pipeline_store_has_edge_between_qns(const char *db_path, const char *project, + const char *source_qn, const char *type, + const char *target_qn) { + return pipeline_store_edge_between_qns_matches(db_path, project, source_qn, type, target_qn, + NULL); +} + +static bool pipeline_resolved_call_contains(const CBMResolvedCallArray *arr, + const char *caller_substr, + const char *callee_substr) { + if (!arr || !caller_substr || !callee_substr) { + return false; + } + for (int i = 0; i < arr->count; i++) { + const CBMResolvedCall *rc = &arr->items[i]; + if (rc->caller_qn && rc->callee_qn && strstr(rc->caller_qn, caller_substr) && + strstr(rc->callee_qn, callee_substr)) { + return true; + } + } + return false; +} + +static bool pipeline_text_array_contains(char *const *items, int count, const char *needle) { + if (!items || !needle) { + return false; + } + for (int i = 0; i < count; i++) { + if (items[i] && strcmp(items[i], needle) == 0) { + return true; + } + } + return false; +} + +static void pipeline_restore_workers_env(bool had_workers, const char *saved_workers) { + if (had_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + } else { + cbm_unsetenv("CBM_WORKERS"); + } +} + +static int pipeline_run_with_worker_count(const char *repo_path, const char *db_path, int workers, + char **out_project) { + char worker_buf[CBM_SZ_32]; + int n = snprintf(worker_buf, sizeof(worker_buf), "%d", workers); + if (n <= 0 || (size_t)n >= sizeof(worker_buf) || + cbm_setenv("CBM_WORKERS", worker_buf, 1) != 0) { + return CBM_NOT_FOUND; + } + + cbm_pipeline_t *p = cbm_pipeline_new(repo_path, db_path, CBM_MODE_FULL); + if (!p) { + return CBM_NOT_FOUND; + } + int rc = cbm_pipeline_run(p); + if (rc == 0 && out_project) { + *out_project = strdup(cbm_pipeline_project_name(p)); + if (!*out_project) { + rc = CBM_NOT_FOUND; + } + } + cbm_pipeline_free(p); + return rc; +} + +static int pipeline_count_channel_edges_to_non_channels(const char *db_path, const char *project) { + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + return CBM_NOT_FOUND; + } + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *stmt = NULL; + int count = CBM_NOT_FOUND; + static const char sql[] = + "SELECT COUNT(*) " + "FROM edges e JOIN nodes t ON t.id = e.target_id " + "WHERE e.project = ?1 AND e.type IN ('EMITS','LISTENS_ON') AND t.label <> 'Channel'"; + if (db && sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, SKIP_ONE, project, CBM_NOT_FOUND, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + } + sqlite3_finalize(stmt); + cbm_store_close(s); + return count; +} + TEST(pipeline_python_project) { /* Port of TestPipelinePythonProject */ const char *files[] = {"main.py", "utils.py"}; @@ -2165,19 +3518,26 @@ TEST(pipeline_imports_multi_symbol_edges) { PASS(); } -TEST(pipeline_go_cross_package_call) { - /* Port of TestGoCrossPackageCallViaImport */ - const char *files[] = {"main.go", "svc/handler.go"}; +TEST(pipeline_typescript_barrel_reexport_call_resolves_implementation) { + enum { FILE_COUNT = 3 }; + const char *files[] = {"nested/feature-adapter/implementation.ts", + "nested/feature-adapter/barrel.ts", + "nested/feature-adapter/consumer.ts"}; const char *contents[] = { - "package main\n\nimport \"example.com/myapp/svc\"\n\n" - "func run() {\n\tsvc.ProcessOrder(\"123\")\n}\n", - - "package svc\n\nfunc ProcessOrder(id string) error {\n\treturn nil\n}\n"}; - - if (setup_lang_repo(files, contents, 2) != 0) + "export async function targetOperation(): Promise {\n return;\n}\n", + "export { targetOperation } from './implementation';\n", + "import {\n targetOperation,\n} from './barrel';\n\n" + "export async function callerOperation(): Promise {\n" + " await targetOperation();\n" + "}\n"}; + + if (setup_lang_repo(files, contents, FILE_COUNT) != 0) { FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + } + char db[CBM_SZ_512]; + int n = snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db)); cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); ASSERT_NOT_NULL(p); @@ -2185,12 +3545,76 @@ TEST(pipeline_go_cross_package_call) { cbm_store_t *s = cbm_store_open_path(db); ASSERT_NOT_NULL(s); - const char *proj = cbm_pipeline_project_name(p); + ASSERT_TRUE(cross_file_call_exists(s, cbm_pipeline_project_name(p), "callerOperation", + "targetOperation")); - /* Verify ProcessOrder exists */ - cbm_node_t *targets = NULL; - int tc = 0; - cbm_store_find_nodes_by_name(s, proj, "ProcessOrder", &targets, &tc); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(pipeline_python_pyo3_import_resolves_rust_function_calls) { + enum { FILE_COUNT = 2 }; + const char *files[] = {"native_bridge/src/lib.rs", "python_package/entrypoint.py"}; + const char *contents[] = { + "#[pyfunction]\nfn native_execute() -> i32 { 42 }\n\n" + "#[pyfunction]\nfn serve_protocol() {}\n", + "def cli_main():\n" + " from python_package._native import native_execute, serve_protocol\n" + " serve_protocol()\n" + " return native_execute()\n"}; + + if (setup_lang_repo(files, contents, FILE_COUNT) != 0) { + FAIL("tmpdir"); + } + char db[CBM_SZ_512]; + int n = snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db)); + + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + ASSERT_TRUE(cross_file_call_exists(s, project, "cli_main", "native_execute")); + ASSERT_TRUE(cross_file_call_exists(s, project, "cli_main", "serve_protocol")); + + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(pipeline_go_cross_package_call) { + /* Port of TestGoCrossPackageCallViaImport */ + const char *files[] = {"main.go", "svc/handler.go"}; + const char *contents[] = { + "package main\n\nimport \"example.com/myapp/svc\"\n\n" + "func run() {\n\tsvc.ProcessOrder(\"123\")\n}\n", + + "package svc\n\nfunc ProcessOrder(id string) error {\n\treturn nil\n}\n"}; + + if (setup_lang_repo(files, contents, 2) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + /* Verify ProcessOrder exists */ + cbm_node_t *targets = NULL; + int tc = 0; + cbm_store_find_nodes_by_name(s, proj, "ProcessOrder", &targets, &tc); ASSERT_GT(tc, 0); /* Verify run() exists */ @@ -2370,6 +3794,419 @@ TEST(pipeline_python_cross_module_call) { PASS(); } +TEST(pipeline_python_reexport_call_uses_resolved_import_edge) { + enum { REEXPORT_FILE_COUNT = 4 }; + const char *files[] = {"fastapi/__init__.py", "fastapi/param_functions.py", + "fastapi/openapi/models.py", "docs_src/app/main.py"}; + const char *contents[] = { + "from .param_functions import Header\n", + "def Header(default=None):\n return default\n", + "class Header:\n pass\n", + ("from fastapi import Header\n\n" + "def create_item():\n return Header(None)\n")}; + + if (setup_lang_repo(files, contents, REEXPORT_FILE_COUNT) != 0) { + FAIL("tmpdir"); + } + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + cbm_node_t *callers = NULL; + int caller_count = 0; + cbm_store_find_nodes_by_name(s, proj, "create_item", &callers, &caller_count); + ASSERT_GT(caller_count, 0); + + cbm_node_t *headers = NULL; + int header_count = 0; + cbm_store_find_nodes_by_name(s, proj, "Header", &headers, &header_count); + ASSERT_GT(header_count, 1); + + int64_t expected_target_id = 0; + int64_t wrong_target_id = 0; + for (int i = 0; i < header_count; i++) { + const char *qn = headers[i].qualified_name ? headers[i].qualified_name : ""; + if (strstr(qn, ".fastapi.param_functions.Header")) { + expected_target_id = headers[i].id; + } else if (strstr(qn, ".fastapi.openapi.models.Header")) { + wrong_target_id = headers[i].id; + } + } + ASSERT_GT(expected_target_id, 0); + ASSERT_GT(wrong_target_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_source_type(s, callers[0].id, "CALLS", &edges, &edge_count); + bool found_expected = false; + bool found_wrong = false; + for (int i = 0; i < edge_count; i++) { + if (edges[i].target_id == expected_target_id) { + found_expected = true; + } + if (edges[i].target_id == wrong_target_id) { + found_wrong = true; + } + } + ASSERT_TRUE(found_expected); + ASSERT_FALSE(found_wrong); + + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + cbm_store_free_nodes(headers, header_count); + cbm_store_free_nodes(callers, caller_count); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(pipeline_incremental_reexport_target_matches_full) { + enum { REEXPORT_AFFECTED_PATHS = 2 }; + enum { REEXPORT_FILE_COUNT = 4 }; + const char *files[] = {"fastapi/__init__.py", "fastapi/param_functions.py", + "fastapi/openapi/models.py", "docs_src/app/main.py"}; + const char *contents[] = { + "from .param_functions import Header\n", + "def Header(default=None):\n return default\n", + "class Header:\n pass\n", + ("from fastapi import Header\n\n" + "def create_item():\n return Header(None)\n")}; + + if (setup_lang_repo(files, contents, REEXPORT_FILE_COUNT) != 0) { + FAIL("tmpdir"); + } + + char db[CBM_SZ_512]; + int n = snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db)); + + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + ASSERT_EQ(th_write_file(TH_PATH(g_lang_tmpdir, "fastapi/__init__.py"), + "from .openapi.models import Header\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_lang_tmpdir); + ASSERT_NOT_NULL(cfg); + char affected_cap[CBM_SZ_32]; + n = snprintf(affected_cap, sizeof(affected_cap), "%d", CBM_SZ_64); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(affected_cap)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, + affected_cap), + 0); + p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.exact.frontier changed=1 expanded=2") != NULL || + strstr(logs, "msg=incremental.frontier changed=1 expanded=2") != NULL); + cbm_pipeline_publish_kind_t kind = cbm_pipeline_publish_kind(p); + ASSERT(kind == CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT || + kind == CBM_PIPELINE_PUBLISH_INCREMENTAL_CONTAINMENT); + if (kind == CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT) { + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, REEXPORT_AFFECTED_PATHS); + ASSERT_EQ(stats.published_paths, REEXPORT_AFFECTED_PATHS); + } else { + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "missing_existing_ownership"); + } + cbm_pipeline_free(p); + cbm_config_close(cfg); + + char incremental_db[CBM_SZ_512]; + n = snprintf(incremental_db, sizeof(incremental_db), "%s/reexport-incremental.db", + g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(incremental_db)); + cbm_unlink(incremental_db); + ASSERT_EQ(pipeline_dump_store_file_to_file(db, incremental_db), CBM_STORE_OK); + + cbm_unlink(db); + p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = + cbm_test_compare_canonical_graphs(incremental_db, db, project, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + printf(" [incremental:reexport-diff] %s\n", diff_err); + } + ASSERT_EQ(diff_rc, 0); + + cbm_unlink(incremental_db); + free(project); + teardown_lang_repo(); + PASS(); +} + +TEST(pipeline_parallel_duplicate_import_inherits_matches_sequential) { + enum { FILLER_FILE_COUNT = 52, SEQUENTIAL_WORKERS = 1, PARALLEL_WORKERS = 4 }; + const char *files[] = {"fastapi/openapi/models.py", "fastapi/security/base.py", + "fastapi/security/api_key.py"}; + const char *contents[] = { + "class SecurityBase:\n pass\n", + "from fastapi.openapi.models import SecurityBase as SecurityBaseModel\n\n" + "class SecurityBase:\n model: SecurityBaseModel\n", + "from fastapi.security.base import SecurityBase\n\n" + "class APIKeyBase(SecurityBase):\n pass\n"}; + + if (setup_lang_repo(files, contents, 3) != 0) { + FAIL("tmpdir"); + } + for (int i = 0; i < FILLER_FILE_COUNT; i++) { + char rel[CBM_SZ_128]; + char body[CBM_SZ_256]; + int rn = snprintf(rel, sizeof(rel), "fillers/filler_%02d.py", i); + int bn = snprintf(body, sizeof(body), "def filler_%02d():\n return %d\n", i, i); + ASSERT_GT(rn, 0); + ASSERT_LT((size_t)rn, sizeof(rel)); + ASSERT_GT(bn, 0); + ASSERT_LT((size_t)bn, sizeof(body)); + ASSERT_EQ(th_write_file(TH_PATH(g_lang_tmpdir, rel), body), 0); + } + + char seq_db[CBM_SZ_512]; + char par_db[CBM_SZ_512]; + int n = snprintf(seq_db, sizeof(seq_db), "%s/seq.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(seq_db)); + n = snprintf(par_db, sizeof(par_db), "%s/par.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(par_db)); + + char saved_workers[CBM_SZ_32] = {0}; + bool had_workers = cbm_safe_getenv("CBM_WORKERS", saved_workers, sizeof(saved_workers), + NULL) != NULL; + + char *project = NULL; + int seq_rc = + pipeline_run_with_worker_count(g_lang_tmpdir, seq_db, SEQUENTIAL_WORKERS, &project); + int par_rc = pipeline_run_with_worker_count(g_lang_tmpdir, par_db, PARALLEL_WORKERS, NULL); + pipeline_restore_workers_env(had_workers, saved_workers); + ASSERT_EQ(seq_rc, 0); + ASSERT_EQ(par_rc, 0); + ASSERT_NOT_NULL(project); + + char src_qn[CBM_SZ_512]; + char target_qn[CBM_SZ_512]; + n = snprintf(src_qn, sizeof(src_qn), "%s.fastapi.security.api_key.APIKeyBase", project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(src_qn)); + n = snprintf(target_qn, sizeof(target_qn), "%s.fastapi.security.base.SecurityBase", project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(target_qn)); + + char openapi_module_qn[CBM_SZ_512]; + char *base_file_qn = cbm_pipeline_fqn_compute(project, "fastapi/security/base.py", "__file__"); + ASSERT_NOT_NULL(base_file_qn); + n = snprintf(openapi_module_qn, sizeof(openapi_module_qn), "%s.fastapi.openapi.models", + project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(openapi_module_qn)); + + ASSERT_TRUE(pipeline_store_has_edge_between_qns(seq_db, project, base_file_qn, "IMPORTS", + openapi_module_qn)); + ASSERT_TRUE(pipeline_store_has_edge_between_qns(par_db, project, base_file_qn, "IMPORTS", + openapi_module_qn)); + ASSERT_TRUE( + pipeline_store_has_edge_between_qns(seq_db, project, src_qn, "INHERITS", target_qn)); + ASSERT_TRUE( + pipeline_store_has_edge_between_qns(par_db, project, src_qn, "INHERITS", target_qn)); + + free(base_file_qn); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = cbm_test_compare_canonical_graphs(seq_db, par_db, project, diff_err, + sizeof(diff_err)); + if (diff_rc != 0) { + printf(" [parallel:duplicate-import-diff] %s\n", diff_err); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + teardown_lang_repo(); + PASS(); +} + +TEST(pipeline_parallel_env_access_matches_sequential) { + enum { + FILLER_FILE_COUNT = 52, + FILE_COUNT = FILLER_FILE_COUNT + 1, + SEQUENTIAL_WORKERS = 1, + PARALLEL_WORKERS = 4 + }; + const char *files[FILE_COUNT]; + const char *contents[FILE_COUNT]; + char filler_files[FILLER_FILE_COUNT][CBM_SZ_64]; + char filler_bodies[FILLER_FILE_COUNT][CBM_SZ_128]; + + files[0] = "src/env.c"; + contents[0] = "#include \n\n" + "const char *cbm_safe_getenv(const char *key, char *buf, unsigned long cap, " + "void *err) {\n" + " (void)buf;\n" + " (void)cap;\n" + " (void)err;\n" + " return key;\n" + "}\n\n" + "const char *load_temp(void) {\n" + " return getenv(\"CBM_TEST_PARALLEL_ENV\");\n" + "}\n\n" + "const char *load_home(void) {\n" + " char buf[32];\n" + " return cbm_safe_getenv(\"HOME\", buf, sizeof(buf), NULL);\n" + "}\n"; + for (int i = 0; i < FILLER_FILE_COUNT; i++) { + int rn = snprintf(filler_files[i], sizeof(filler_files[i]), "fillers/filler_%02d.c", i); + int bn = snprintf(filler_bodies[i], sizeof(filler_bodies[i]), + "int filler_%02d(void) {\n return %d;\n}\n", i, i); + ASSERT_GT(rn, 0); + ASSERT_LT((size_t)rn, sizeof(filler_files[i])); + ASSERT_GT(bn, 0); + ASSERT_LT((size_t)bn, sizeof(filler_bodies[i])); + files[i + 1] = filler_files[i]; + contents[i + 1] = filler_bodies[i]; + } + + if (setup_lang_repo(files, contents, FILE_COUNT) != 0) { + FAIL("tmpdir"); + } + + char seq_db[CBM_SZ_512]; + char par_db[CBM_SZ_512]; + int n = snprintf(seq_db, sizeof(seq_db), "%s/env-seq.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(seq_db)); + n = snprintf(par_db, sizeof(par_db), "%s/env-par.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(par_db)); + + char saved_workers[CBM_SZ_32] = {0}; + bool had_workers = cbm_safe_getenv("CBM_WORKERS", saved_workers, sizeof(saved_workers), + NULL) != NULL; + + char *project = NULL; + int seq_rc = + pipeline_run_with_worker_count(g_lang_tmpdir, seq_db, SEQUENTIAL_WORKERS, &project); + int par_rc = pipeline_run_with_worker_count(g_lang_tmpdir, par_db, PARALLEL_WORKERS, NULL); + pipeline_restore_workers_env(had_workers, saved_workers); + ASSERT_EQ(seq_rc, 0); + ASSERT_EQ(par_rc, 0); + ASSERT_NOT_NULL(project); + + char env_source_qn[CBM_SZ_512]; + n = snprintf(env_source_qn, sizeof(env_source_qn), "%s.src.env.load_temp", project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(env_source_qn)); + const char *env_qn = "__env__CBM_TEST_PARALLEL_ENV"; + + ASSERT_TRUE(pipeline_store_has_edge_between_qns(seq_db, project, env_source_qn, "CONFIGURES", + env_qn)); + ASSERT_TRUE(pipeline_store_has_edge_between_qns(par_db, project, env_source_qn, "CONFIGURES", + env_qn)); + char call_source_qn[CBM_SZ_512]; + n = snprintf(call_source_qn, sizeof(call_source_qn), "%s.src.env.load_home", project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(call_source_qn)); + char call_target_qn[CBM_SZ_512]; + n = snprintf(call_target_qn, sizeof(call_target_qn), "%s.src.env.cbm_safe_getenv", project); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(call_target_qn)); + ASSERT_TRUE(pipeline_store_edge_between_qns_matches(seq_db, project, call_source_qn, + "CONFIGURES", call_target_qn, + "\"args\":[")); + ASSERT_TRUE(pipeline_store_edge_between_qns_matches(par_db, project, call_source_qn, + "CONFIGURES", call_target_qn, + "\"args\":[")); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = cbm_test_compare_canonical_graphs(seq_db, par_db, project, diff_err, + sizeof(diff_err)); + if (diff_rc != 0) { + printf(" [parallel:env-access-diff] %s\n", diff_err); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + teardown_lang_repo(); + PASS(); +} + +TEST(pipeline_parallel_channel_edges_target_channels) { + enum { FILLER_FILE_COUNT = 52, PARALLEL_WORKERS = 4 }; + const char *files[] = {"app/main.py"}; + const char *contents[] = { + "from fastapi import FastAPI, WebSocket\n\n" + "app = FastAPI()\n\n" + "def marker(fn):\n return fn\n\n" + "@app.websocket('/ws')\n" + "async def ws(websocket: WebSocket):\n" + " await websocket.accept()\n" + " await websocket.send_text('Hello, router!')\n" + " await websocket.send_text('Hello, world!')\n\n" + "@app.get('/items')\n" + "def read_items():\n" + " return {'ok': True}\n\n" + "@marker\n" + "def decorated():\n" + " return read_items()\n"}; + + if (setup_lang_repo(files, contents, 1) != 0) { + FAIL("tmpdir"); + } + for (int i = 0; i < FILLER_FILE_COUNT; i++) { + char rel[CBM_SZ_128]; + char body[CBM_SZ_256]; + int rn = snprintf(rel, sizeof(rel), "fillers/filler_%02d.py", i); + int bn = snprintf(body, sizeof(body), "def filler_%02d():\n return %d\n", i, i); + ASSERT_GT(rn, 0); + ASSERT_LT((size_t)rn, sizeof(rel)); + ASSERT_GT(bn, 0); + ASSERT_LT((size_t)bn, sizeof(body)); + ASSERT_EQ(th_write_file(TH_PATH(g_lang_tmpdir, rel), body), 0); + } + + char db[CBM_SZ_512]; + int n = snprintf(db, sizeof(db), "%s/channel-parallel.db", g_lang_tmpdir); + ASSERT_GT(n, 0); + ASSERT_LT((size_t)n, sizeof(db)); + + char saved_workers[CBM_SZ_32] = {0}; + bool had_workers = cbm_safe_getenv("CBM_WORKERS", saved_workers, sizeof(saved_workers), + NULL) != NULL; + char *project = NULL; + int rc = pipeline_run_with_worker_count(g_lang_tmpdir, db, PARALLEL_WORKERS, &project); + pipeline_restore_workers_env(had_workers, saved_workers); + ASSERT_EQ(rc, 0); + ASSERT_NOT_NULL(project); + ASSERT_EQ(pipeline_count_channel_edges_to_non_channels(db, project), 0); + + free(project); + teardown_lang_repo(); + PASS(); +} + TEST(pipeline_go_type_classification) { /* Port of TestGoTypeClassification */ const char *files[] = {"types.go"}; @@ -2400,12 +4237,12 @@ TEST(pipeline_go_type_classification) { cbm_store_free_nodes(ifaces, ic); /* Should have 1 Struct node (Config struct) */ - cbm_node_t *cls = NULL; - int cc = 0; - cbm_store_find_nodes_by_label(s, proj, "Struct", &cls, &cc); - ASSERT_EQ(cc, 1); - ASSERT_STR_EQ(cls[0].name, "Config"); - cbm_store_free_nodes(cls, cc); + cbm_node_t *structs = NULL; + int sc = 0; + cbm_store_find_nodes_by_label(s, proj, "Struct", &structs, &sc); + ASSERT_EQ(sc, 1); + ASSERT_STR_EQ(structs[0].name, "Config"); + cbm_store_free_nodes(structs, sc); /* Should have 1 Type node (ID alias) */ cbm_node_t *types = NULL; @@ -2444,11 +4281,11 @@ TEST(pipeline_go_grouped_types) { ASSERT_NOT_NULL(s); const char *proj = cbm_pipeline_project_name(p); - cbm_node_t *cls = NULL; - int cc = 0; - cbm_store_find_nodes_by_label(s, proj, "Struct", &cls, &cc); - ASSERT_EQ(cc, 2); /* Request, Response */ - cbm_store_free_nodes(cls, cc); + cbm_node_t *structs = NULL; + int sc = 0; + cbm_store_find_nodes_by_label(s, proj, "Struct", &structs, &sc); + ASSERT_EQ(sc, 2); /* Request, Response */ + cbm_store_free_nodes(structs, sc); cbm_node_t *ifaces = NULL; int ic = 0; @@ -2934,7 +4771,7 @@ TEST(pipeline_docstring_kotlin_function) { PASS(); } -TEST(pipeline_docstring_go_class) { +TEST(pipeline_docstring_go_struct) { /* Go struct with // comment docstring */ const char *files[] = {"main.go"}; const char *contents[] = {"package main\n\n" @@ -3061,6 +4898,20 @@ TEST(git_context_non_git_path) { ASSERT_NOT_NULL(strstr(json, "\"is_git\":false")); ASSERT_NOT_NULL(strstr(json, "\"root_exists\":true")); + const char control_root[] = {'r', 'o', 'o', 't', '\f', 'p', 'a', 't', 'h', '\0'}; + cbm_git_context_t control_ctx = { + .root_exists = true, + .canonical_root = (char *)control_root, + }; + ASSERT_GT(cbm_git_context_props_json(&control_ctx, json, sizeof(json)), 0); + ASSERT_NOT_NULL(strstr(json, "\"canonical_root\":\"root\\u000cpath\"")); + yyjson_doc *control_doc = yyjson_read(json, strlen(json), 0); + ASSERT_NOT_NULL(control_doc); + yyjson_val *control_value = yyjson_obj_get(yyjson_doc_get_root(control_doc), "canonical_root"); + ASSERT_NOT_NULL(control_value); + ASSERT_STR_EQ(yyjson_get_str(control_value), control_root); + yyjson_doc_free(control_doc); + char long_value[1200]; memset(long_value, 'a', sizeof(long_value) - 1); long_value[sizeof(long_value) - 1] = '\0'; @@ -3309,973 +5160,4076 @@ TEST(gitdiff_parse_hunks_deletion) { PASS(); } -/* ── Config helpers (pass_configures.c) ───────────────────────── */ +static const cbm_store_delta_edge_t *pipeline_delta_find_edge(const cbm_pipeline_file_delta_t *delta, + const char *type) { + for (int i = 0; i < delta->delta.edge_count; i++) { + if (strcmp(delta->edges[i].type, type) == 0) { + return &delta->edges[i]; + } + } + return NULL; +} -TEST(configures_is_env_var_name) { - ASSERT(cbm_is_env_var_name("DATABASE_URL")); - ASSERT(cbm_is_env_var_name("API_KEY")); - ASSERT(cbm_is_env_var_name("PORT")); - ASSERT(!cbm_is_env_var_name("A")); /* too short */ - ASSERT(!cbm_is_env_var_name("port")); /* lowercase */ - ASSERT(!cbm_is_env_var_name("apiKey")); /* camelCase */ - ASSERT(cbm_is_env_var_name("DB_2")); /* with digit */ - ASSERT(!cbm_is_env_var_name("__")); /* no uppercase */ - ASSERT(!cbm_is_env_var_name("")); /* empty */ - PASS(); +static const cbm_store_delta_edge_t *pipeline_delta_find_edge_by_qn( + const cbm_pipeline_file_delta_t *delta, const char *source_qn, const char *target_qn, + const char *type) { + for (int i = 0; i < delta->delta.edge_count; i++) { + const cbm_store_delta_edge_t *edge = &delta->edges[i]; + if (edge->source_qn && edge->target_qn && edge->type && + strcmp(edge->source_qn, source_qn) == 0 && + strcmp(edge->target_qn, target_qn) == 0 && strcmp(edge->type, type) == 0) { + return edge; + } + } + return NULL; } -TEST(configures_normalize_config_key) { - char norm[256]; - int tokens; +static const cbm_store_import_ref_t *pipeline_delta_first_import( + const cbm_pipeline_file_delta_t *delta) { + return delta->delta.import_count > 0 ? &delta->imports[0] : NULL; +} - tokens = cbm_normalize_config_key("max_connections", norm, sizeof(norm)); - ASSERT_STR_EQ(norm, "max_connections"); - ASSERT_EQ(tokens, 2); +static int pipeline_delta_plan_contains_path(const cbm_pipeline_file_delta_plan_t *plan, + const char *path) { + for (int i = 0; i < plan->affected_count; i++) { + if (strcmp(plan->affected_paths[i], path) == 0) { + return 1; + } + } + return 0; +} - tokens = cbm_normalize_config_key("maxConnections", norm, sizeof(norm)); - ASSERT_STR_EQ(norm, "max_connections"); - ASSERT_EQ(tokens, 2); +static void pipeline_delta_free_string_array(char **items, int count) { + if (!items) { + return; + } + for (int i = 0; i < count; i++) { + free(items[i]); + } + free(items); +} - tokens = cbm_normalize_config_key("DATABASE_HOST", norm, sizeof(norm)); - ASSERT_STR_EQ(norm, "database_host"); - ASSERT_EQ(tokens, 2); +static int pipeline_delta_store_qn_exists(cbm_store_t *s, const char *project, const char *qn) { + cbm_node_t node = {0}; + int rc = cbm_store_find_node_by_qn(s, project, qn, &node); + if (rc == CBM_STORE_OK) { + cbm_node_free_fields(&node); + return 1; + } + return 0; +} - tokens = cbm_normalize_config_key("database.host", norm, sizeof(norm)); - ASSERT_STR_EQ(norm, "database_host"); - ASSERT_EQ(tokens, 2); +TEST(pipeline_file_delta_detects_cross_file_node_qn_collision) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t existing = {.project = "test", + .label = "Class", + .name = "Thing", + .qualified_name = "test.src.store.store.Thing", + .file_path = "src/store/store.c", + .start_line = 1, + .end_line = 4, + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &existing), 0); + + cbm_node_t delta_node = {.project = "test", + .label = "Class", + .name = "Thing", + .qualified_name = "test.src.store.store.Thing", + .file_path = "src/store/store.h", + .start_line = 1, + .end_line = 1, + .properties_json = "{}"}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", .rel_path = "src/store/store.h", .node_count = 1}, + .nodes = &delta_node, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_UPSERT, + }; + delta.delta.nodes = &delta_node; - tokens = cbm_normalize_config_key("port", norm, sizeof(norm)); - ASSERT_STR_EQ(norm, "port"); - ASSERT_EQ(tokens, 1); + bool collision = false; + ASSERT_EQ(cbm_pipeline_file_delta_has_cross_file_node_qn_collision(s, &delta, &collision), + CBM_STORE_OK); + ASSERT_FALSE(collision); + + cbm_node_t existing_var = {.project = "test", + .label = "Variable", + .name = "thing", + .qualified_name = "test.src.store.store.thing", + .file_path = "src/store/store.c", + .start_line = 1, + .end_line = 1, + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &existing_var), 0); + delta_node.label = "Variable"; + delta_node.name = "thing"; + delta_node.qualified_name = "test.src.store.store.thing"; + collision = false; + ASSERT_EQ(cbm_pipeline_file_delta_has_cross_file_node_qn_collision(s, &delta, &collision), + CBM_STORE_OK); + ASSERT_TRUE(collision); + + delta_node.label = "Class"; + delta_node.name = "Thing"; + delta_node.qualified_name = "test.src.store.store.Thing"; + delta_node.file_path = "src/store/store.c"; + collision = true; + ASSERT_EQ(cbm_pipeline_file_delta_has_cross_file_node_qn_collision(s, &delta, &collision), + CBM_STORE_OK); + ASSERT_FALSE(collision); - tokens = cbm_normalize_config_key("maxRetryCount", norm, sizeof(norm)); - ASSERT_STR_EQ(norm, "max_retry_count"); - ASSERT_EQ(tokens, 3); + cbm_store_close(s); PASS(); } -TEST(configures_has_config_extension) { - ASSERT(cbm_has_config_extension("config.toml")); - ASSERT(cbm_has_config_extension("settings.yaml")); - ASSERT(cbm_has_config_extension("config.yml")); - ASSERT(cbm_has_config_extension(".env")); - ASSERT(cbm_has_config_extension("config.ini")); - ASSERT(cbm_has_config_extension("data.json")); - ASSERT(cbm_has_config_extension("pom.xml")); - ASSERT(!cbm_has_config_extension("main.go")); - ASSERT(!cbm_has_config_extension("app.py")); - ASSERT(!cbm_has_config_extension("data.csv")); - PASS(); +static void pipeline_delta_attach_test_metadata(cbm_pipeline_file_delta_t *delta, + cbm_file_hash_t *hash, + cbm_file_state_t *state) { + enum { PIPELINE_DELTA_TEST_GENERATION = 1 }; + *hash = (cbm_file_hash_t){.project = delta->delta.project, + .rel_path = delta->delta.rel_path, + .sha256 = "test-hash", + .mtime_ns = 1, + .size = 10}; + *state = (cbm_file_state_t){.project = delta->delta.project, + .rel_path = delta->delta.rel_path, + .content_hash = "test-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "go", + .pass_fingerprint = "test-pass", + .generation = PIPELINE_DELTA_TEST_GENERATION, + .indexed_at = "2026-06-30T00:00:00Z"}; + delta->delta.generation = PIPELINE_DELTA_TEST_GENERATION; + delta->delta.file_hash = hash; + delta->delta.file_state = state; +} + +static int64_t pipeline_delta_seed_existing_ownership_id(cbm_store_t *s, const char *project, + const char *rel_path, + const char *qualified_name) { + enum { PIPELINE_DELTA_TEST_BASE_GENERATION = 1 }; + cbm_node_t node = {.project = (char *)project, + .label = "Function", + .name = "Existing", + .qualified_name = (char *)qualified_name, + .file_path = (char *)rel_path, + .start_line = 1, + .end_line = 1, + .properties_json = "{}"}; + int64_t node_id = cbm_store_upsert_node(s, &node); + if (node_id <= CBM_STORE_NO_NODE_ID) { + return CBM_STORE_NO_NODE_ID; + } + cbm_file_state_t state = {.project = (char *)project, + .rel_path = (char *)rel_path, + .content_hash = "base-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "go", + .pass_fingerprint = "test-pass", + .generation = PIPELINE_DELTA_TEST_BASE_GENERATION, + .indexed_at = "2026-06-30T00:00:00Z"}; + if (cbm_store_upsert_file_state(s, &state) != CBM_STORE_OK) { + return CBM_STORE_NO_NODE_ID; + } + if (cbm_store_upsert_node_owner(s, project, node_id, rel_path, + PIPELINE_DELTA_TEST_BASE_GENERATION) != CBM_STORE_OK) { + return CBM_STORE_NO_NODE_ID; + } + return node_id; } -/* ── Config integration tests (configures_test.go ports) ──────── */ +static int pipeline_delta_seed_existing_ownership(cbm_store_t *s, const char *project, + const char *rel_path, + const char *qualified_name) { + return pipeline_delta_seed_existing_ownership_id(s, project, rel_path, qualified_name) > + CBM_STORE_NO_NODE_ID + ? CBM_STORE_OK + : CBM_STORE_ERR; +} -TEST(configures_env_var_in_config) { - /* Port of TestBuildEnvIndex_ConfigVariableAdded: - * config.toml has DATABASE_URL, main.go does os.Getenv("DATABASE_URL") - * → CONFIGURES edges should link them. */ - const char *files[] = {"config.toml", "main.go"}; - const char *contents[] = {"DATABASE_URL = \"postgresql://localhost/db\"\n", +static int pipeline_delta_seed_file_owned_unowned_source_edge( + cbm_store_t *s, const char *project, const char *rel_path, const char *source_qn, + const char *target_qn, const char *edge_type) { + enum { PIPELINE_DELTA_TEST_BASE_GENERATION = 1 }; + int64_t target_id = pipeline_delta_seed_existing_ownership_id(s, project, rel_path, target_qn); + if (target_id <= CBM_STORE_NO_NODE_ID) { + return CBM_STORE_ERR; + } + cbm_node_t source = {.project = (char *)project, + .label = "Module", + .name = "module", + .qualified_name = (char *)source_qn, + .file_path = "", + .properties_json = "{}"}; + int64_t source_id = cbm_store_upsert_node(s, &source); + if (source_id <= CBM_STORE_NO_NODE_ID) { + return CBM_STORE_ERR; + } + cbm_edge_t edge = {.project = (char *)project, + .source_id = source_id, + .target_id = target_id, + .type = (char *)edge_type, + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &edge); + if (edge_id <= CBM_STORE_NO_NODE_ID) { + return CBM_STORE_ERR; + } + return cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL, + PIPELINE_DELTA_TEST_BASE_GENERATION); +} + +static int pipeline_delta_seed_project_node(cbm_store_t *s, const char *project) { + cbm_node_t project_node = {.project = (char *)project, + .label = "Project", + .name = (char *)project, + .qualified_name = (char *)project, + .file_path = "", + .properties_json = "{}"}; + return cbm_store_upsert_node(s, &project_node) > CBM_STORE_NO_NODE_ID ? CBM_STORE_OK + : CBM_STORE_ERR; +} + +static int pipeline_store_file_state_generation_memory(cbm_store_t *s, const char *project, + const char *rel_path, + int64_t *generation) { + cbm_file_state_t state = {0}; + int rc = cbm_store_get_file_state(s, project, rel_path, &state); + if (rc == CBM_STORE_OK && generation) { + *generation = state.generation; + } + cbm_store_file_state_free_fields(&state); + return rc; +} - "package main\n\n" - "import \"os\"\n\n" - "func main() {\n" - "\turl := os.Getenv(\"DATABASE_URL\")\n" - "\t_ = url\n" - "}\n"}; - if (setup_lang_repo(files, contents, 2) != 0) - FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - - cbm_store_t *s = cbm_store_open_path(db); +TEST(pipeline_file_delta_scratch_seed_excludes_changed_paths) { + const char *project = "test"; + const char *changed_paths[] = {"main.go"}; + const int changed_path_count = (int)(sizeof(changed_paths) / sizeof(changed_paths[0])); + cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); - const char *proj = cbm_pipeline_project_name(p); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp"), CBM_STORE_OK); + + cbm_node_t helper = {.project = (char *)project, + .label = "Function", + .name = "Helper", + .qualified_name = "test.helper.Helper", + .file_path = "helper.go", + .start_line = 1, + .end_line = 1, + .properties_json = "{\"is_exported\":true}"}; + cbm_node_t stale = {.project = (char *)project, + .label = "Function", + .name = "Old", + .qualified_name = "test.main.Old", + .file_path = "main.go", + .start_line = 1, + .end_line = 1, + .properties_json = "{\"is_exported\":true}"}; + cbm_node_t module = {.project = (char *)project, + .label = "Module", + .name = "helper", + .qualified_name = "test.helper", + .file_path = "helper.go", + .start_line = 1, + .end_line = 1, + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &helper), 0); + ASSERT_GT(cbm_store_upsert_node(s, &stale), 0); + ASSERT_GT(cbm_store_upsert_node(s, &module), 0); + + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp"); + cbm_registry_t *registry = cbm_registry_new(); + ASSERT_NOT_NULL(scratch); + ASSERT_NOT_NULL(registry); + ASSERT_EQ(cbm_pipeline_seed_file_delta_scratch_from_store( + s, scratch, registry, project, changed_paths, changed_path_count), + CBM_STORE_OK); - /* Verify CONFIGURES edges were created */ - cbm_edge_t *edges = NULL; - int ec = 0; - cbm_store_find_edges_by_type(s, proj, "CONFIGURES", &edges, &ec); - /* At minimum the pipeline should not crash. Edge count depends on - * extraction matching env var accesses to config variables. */ - if (edges) - cbm_store_free_edges(edges, ec); + ASSERT_NULL(cbm_gbuf_find_by_qn(scratch, "test.helper.Helper")); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(scratch, "test.helper")); + ASSERT_NULL(cbm_gbuf_find_by_qn(scratch, "test.main.Old")); + ASSERT_TRUE(cbm_registry_exists(registry, "test.helper.Helper")); + ASSERT_FALSE(cbm_registry_exists(registry, "test.main.Old")); + cbm_pipeline_ctx_t ctx = {.project_name = project, + .repo_path = "/tmp", + .gbuf = scratch, + .registry = registry, + .store_backed_node_lookup = s, + .store_backed_changed_paths = changed_paths, + .store_backed_changed_path_count = changed_path_count}; + ASSERT_NOT_NULL(cbm_pipeline_find_node_by_qn(&ctx, "test.helper.Helper")); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(scratch, "test.helper.Helper")); + ASSERT_NULL(cbm_pipeline_find_node_by_qn(&ctx, "test.main.Old")); + + cbm_registry_free(registry); + cbm_gbuf_free(scratch); cbm_store_close(s); - cbm_pipeline_free(p); - teardown_lang_repo(); PASS(); } -TEST(configures_lowercase_key_skipped) { - /* Port of TestBuildEnvIndex_LowercaseKeySkipped: - * config.toml has lowercase key — should NOT produce env var CONFIGURES edges. */ - const char *files[] = {"config.toml", "main.go"}; - const char *contents[] = {"database_host = \"localhost\"\n", +TEST(pipeline_file_delta_scratch_seed_preserves_structure_roots) { + enum { PIPELINE_DELTA_STRUCTURE_GENERATION = 1 }; + const char *project = "test"; + const char *repo_path = "/tmp"; + const char *rel_path = "main.go"; + const char *changed_paths[] = {rel_path}; + const int changed_path_count = (int)(sizeof(changed_paths) / sizeof(changed_paths[0])); + const char *branch_qn = "test.branch.main"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, repo_path), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, rel_path, "test.main.Old"), + CBM_STORE_OK); - "package main\n\nfunc main() {}\n"}; - if (setup_lang_repo(files, contents, 2) != 0) - FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); + char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__"); + ASSERT_NOT_NULL(file_qn); + cbm_node_t project_node = {.project = (char *)project, + .label = "Project", + .name = (char *)project, + .qualified_name = (char *)project, + .file_path = NULL, + .properties_json = "{}"}; + cbm_node_t branch_node = {.project = (char *)project, + .label = "Branch", + .name = "main", + .qualified_name = (char *)branch_qn, + .file_path = NULL, + .properties_json = "{}"}; + cbm_node_t file_node = {.project = (char *)project, + .label = "File", + .name = (char *)rel_path, + .qualified_name = file_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &project_node), CBM_STORE_NO_NODE_ID); + int64_t branch_id = cbm_store_upsert_node(s, &branch_node); + int64_t file_id = cbm_store_upsert_node(s, &file_node); + ASSERT_GT(branch_id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(file_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_node_owner(s, project, file_id, rel_path, + PIPELINE_DELTA_STRUCTURE_GENERATION), + CBM_STORE_OK); + cbm_edge_t contains = {.project = (char *)project, + .source_id = branch_id, + .target_id = file_id, + .type = "CONTAINS_FILE", + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &contains); + ASSERT_GT(edge_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL, + PIPELINE_DELTA_STRUCTURE_GENERATION), + CBM_STORE_OK); - cbm_store_t *s = cbm_store_open_path(db); - ASSERT_NOT_NULL(s); - /* Pipeline ran successfully — no crash from lowercase config keys */ + cbm_gbuf_t *scratch = cbm_gbuf_new(project, repo_path); + cbm_registry_t *registry = cbm_registry_new(); + ASSERT_NOT_NULL(scratch); + ASSERT_NOT_NULL(registry); + ASSERT_EQ(cbm_pipeline_seed_file_delta_scratch_from_store( + s, scratch, registry, project, changed_paths, changed_path_count), + CBM_STORE_OK); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(scratch, project)); + ASSERT_NOT_NULL(cbm_gbuf_find_by_qn(scratch, branch_qn)); + ASSERT_NULL(cbm_gbuf_find_by_qn(scratch, file_qn)); + + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, branch_qn, rel_path, NULL), 0); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Function", "New", "test.main.New", rel_path, 1, + 1, "{\"is_exported\":true}"), + 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, rel_path, 0, &delta), + CBM_STORE_OK); + const cbm_store_delta_edge_t *structure_edge = + pipeline_delta_find_edge(&delta, "CONTAINS_FILE"); + ASSERT_NOT_NULL(structure_edge); + ASSERT_STR_EQ(structure_edge->source_qn, branch_qn); + ASSERT_STR_EQ(structure_edge->target_qn, file_qn); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_pipeline_file_delta_free(&delta); + cbm_registry_free(registry); + cbm_gbuf_free(scratch); cbm_store_close(s); - cbm_pipeline_free(p); - teardown_lang_repo(); + free(file_qn); PASS(); } -TEST(configures_non_config_file_skipped) { - /* Port of TestBuildEnvIndex_NonConfigFileSkipped: - * Only Go file, no config file — no config-derived CONFIGURES edges. */ - const char *files[] = {"main.go"}; - const char *contents[] = {"package main\n\n" - "var API_URL = \"https://api.example.com\"\n\n" - "func main() {}\n"}; - if (setup_lang_repo(files, contents, 1) != 0) - FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - - cbm_store_t *s = cbm_store_open_path(db); +TEST(pipeline_file_delta_scratch_seed_supports_external_endpoint_descriptor) { + const char *project = "test"; + const char *changed_paths[] = {"main.go"}; + const int changed_path_count = (int)(sizeof(changed_paths) / sizeof(changed_paths[0])); + const char *helper_qn = "test.helper.Helper"; + const char *main_file_qn = "test.main.__file__"; + const char *main_qn = "test.main.Run"; + cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); - /* No config file → buildEnvIndex should not create config-derived entries */ + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, changed_paths[0], + "test.main.Old"), + CBM_STORE_OK); + + cbm_node_t helper = {.project = (char *)project, + .label = "Function", + .name = "Helper", + .qualified_name = (char *)helper_qn, + .file_path = "helper.go", + .start_line = 1, + .end_line = 1, + .properties_json = "{\"is_exported\":true}"}; + ASSERT_GT(cbm_store_upsert_node(s, &helper), 0); + + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp"); + cbm_registry_t *registry = cbm_registry_new(); + ASSERT_NOT_NULL(scratch); + ASSERT_NOT_NULL(registry); + ASSERT_EQ(cbm_pipeline_seed_file_delta_scratch_from_store( + s, scratch, registry, project, changed_paths, changed_path_count), + CBM_STORE_OK); + + int64_t file_id = + cbm_gbuf_upsert_node(scratch, "File", "main.go", main_file_qn, "main.go", 1, 1, "{}"); + int64_t run_id = cbm_gbuf_upsert_node(scratch, "Function", "Run", main_qn, "main.go", 2, 4, + "{\"is_exported\":true}"); + ASSERT_GT(file_id, 0); + ASSERT_GT(run_id, 0); + cbm_pipeline_ctx_t ctx = {.project_name = project, + .repo_path = "/tmp", + .gbuf = scratch, + .registry = registry, + .store_backed_node_lookup = s, + .store_backed_changed_paths = changed_paths, + .store_backed_changed_path_count = changed_path_count}; + const cbm_gbuf_node_t *helper_node = cbm_pipeline_find_node_by_qn(&ctx, helper_qn); + ASSERT_NOT_NULL(helper_node); + ASSERT_EQ(cbm_pipeline_insert_import_edge(&ctx, file_id, helper_node, "Helper"), 1); + ASSERT_GT(cbm_gbuf_insert_edge(scratch, run_id, helper_node->id, "CALLS", "{}"), 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, changed_paths[0], 1, &delta), + CBM_STORE_OK); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + ASSERT_EQ(delta.delta.edge_count, 2); + ASSERT_NOT_NULL(pipeline_delta_find_edge(&delta, "CALLS")); + ASSERT_NOT_NULL(pipeline_delta_find_edge(&delta, "IMPORTS")); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, changed_paths[0]), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_pipeline_file_delta_free(&delta); + cbm_registry_free(registry); + cbm_gbuf_free(scratch); cbm_store_close(s); - cbm_pipeline_free(p); - teardown_lang_repo(); PASS(); } -TEST(configures_full_pipeline_integration) { - /* Port of TestConfigIntegration_FullPipeline: - * TOML + INI + JSON config files + Go code → Class & Variable nodes from - * config files, plus CONFIGURES edges. */ - const char *files[] = {"config.toml", "settings.ini", "config.json", "main.go"}; - const char *contents[] = {"[database]\n" - "host = \"localhost\"\n" - "port = 5432\n" - "max_connections = 100\n\n" - "[server]\n" - "bind_address = \"0.0.0.0\"\n", +TEST(pipeline_file_delta_descriptor_from_gbuf) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + int64_t file_id = + cbm_gbuf_upsert_node(gb, "File", "main.go", "proj.main.__file__", "main.go", 1, 1, "{}"); + int64_t run_id = cbm_gbuf_upsert_node(gb, "Function", "Run", "proj.main.Run", "main.go", 3, 5, + "{\"is_exported\":true}"); + int64_t helper_id = cbm_gbuf_upsert_node(gb, "Function", "Helper", "proj.helper.Helper", + "helper.go", 1, 3, "{\"is_exported\":true}"); + ASSERT_GT(file_id, 0); + ASSERT_GT(run_id, 0); + ASSERT_GT(helper_id, 0); + + const cbm_gbuf_node_t *helper = cbm_gbuf_find_by_qn(gb, "proj.helper.Helper"); + ASSERT_NOT_NULL(helper); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", .repo_path = "/tmp/proj", .gbuf = gb}; + ASSERT_EQ(cbm_pipeline_insert_import_edge(&ctx, file_id, helper, "Helper"), 1); + ASSERT_GT(cbm_gbuf_insert_edge(gb, run_id, helper_id, "CALLS", "{\"line\":4}"), 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, helper_id, run_id, "CALLS", "{\"line\":2}"), 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(gb, "proj", "main.go", 7, &delta), + CBM_STORE_OK); + ASSERT_EQ(delta.unsupported_edge_count, 0); + ASSERT_EQ(delta.delta.node_count, 2); + ASSERT_EQ(delta.delta.export_count, 1); + ASSERT_EQ(delta.delta.edge_count, 2); + ASSERT_EQ(delta.delta.import_count, 1); + ASSERT_STR_EQ(delta.delta.project, "proj"); + ASSERT_STR_EQ(delta.delta.rel_path, "main.go"); + ASSERT_EQ(delta.delta.generation, 7); + ASSERT_STR_EQ(delta.delta.derived_view_name, CBM_STORE_DERIVED_VIEW_NODES_FTS); + ASSERT_STR_EQ(delta.delta.derived_status, CBM_STORE_DERIVED_STATUS_COMPLETE); + ASSERT_STR_EQ(delta.exports[0].qualified_name, "proj.main.Run"); + + const cbm_store_delta_edge_t *call_edge = pipeline_delta_find_edge(&delta, "CALLS"); + ASSERT_NOT_NULL(call_edge); + ASSERT_STR_EQ(call_edge->source_qn, "proj.main.Run"); + ASSERT_STR_EQ(call_edge->target_qn, "proj.helper.Helper"); + + const cbm_store_import_ref_t *imp = pipeline_delta_first_import(&delta); + ASSERT_NOT_NULL(imp); + ASSERT_STR_EQ(imp->import_text, "proj.helper.Helper"); + ASSERT_STR_EQ(imp->local_name, "Helper"); + ASSERT_STR_EQ(imp->target_qn, "proj.helper.Helper"); + + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(gb); + PASS(); +} - "[database]\n" - "host = localhost\n" - "port = 5432\n", +TEST(pipeline_file_delta_owns_target_header_usage_edges) { + const char *project = "proj"; + const char *header_rel = "src/store/store.h"; + const char *source_module_qn = "proj.src.store.store"; + const char *target_qn = "proj.src.store.store.NewHeaderSymbol"; - "{\"appName\": \"test\", \"maxRetries\": 3}", + cbm_gbuf_t *gb = cbm_gbuf_new(project, "/tmp/proj"); + ASSERT_NOT_NULL(gb); + int64_t source_id = cbm_gbuf_upsert_node(gb, "Module", "store", source_module_qn, + "src/store/store.c", 1, 100, "{}"); + int64_t target_id = + cbm_gbuf_upsert_node(gb, "Function", "NewHeaderSymbol", target_qn, header_rel, 12, + 14, "{\"is_exported\":true}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(target_id, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, source_id, target_id, "USAGE", + "{\"callee\":\"NewHeaderSymbol\"}"), + 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(gb, project, header_rel, 3, &delta), + CBM_STORE_OK); + const cbm_store_delta_edge_t *usage = + pipeline_delta_find_edge_by_qn(&delta, source_module_qn, target_qn, "USAGE"); + ASSERT_NOT_NULL(usage); + ASSERT_STR_EQ(usage->properties_json, "{\"callee\":\"NewHeaderSymbol\"}"); - "package main\n\n" - "import \"os\"\n\n" - "func getMaxConnections() int { return 100 }\n\n" - "func loadConfig() {\n" - "\tcfg := readFile(\"config.toml\")\n" - "\t_ = cfg\n" - "\tdbURL := os.Getenv(\"DATABASE_URL\")\n" - "\t_ = dbURL\n" - "}\n\n" - "func readFile(path string) string { return \"\" }\n"}; - if (setup_lang_repo(files, contents, 4) != 0) - FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(gb); + PASS(); +} - cbm_store_t *s = cbm_store_open_path(db); +TEST(pipeline_file_delta_preserves_safe_inbound_edges_for_overlay) { + const char *project = "test"; + const char *target_rel = "target.go"; + const char *caller_rel = "caller.go"; + const char *target_qn = "test.target.Handle"; + const char *stale_qn = "test.target.Legacy"; + const char *caller_qn = "test.caller.Call"; + + cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); - const char *proj = cbm_pipeline_project_name(p); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + int64_t target_id = + pipeline_delta_seed_existing_ownership_id(s, project, target_rel, target_qn); + int64_t stale_id = pipeline_delta_seed_existing_ownership_id(s, project, target_rel, stale_qn); + int64_t caller_id = + pipeline_delta_seed_existing_ownership_id(s, project, caller_rel, caller_qn); + ASSERT_GT(target_id, 0); + ASSERT_GT(stale_id, 0); + ASSERT_GT(caller_id, 0); + + cbm_edge_t call_edge = {.project = (char *)project, + .source_id = caller_id, + .target_id = target_id, + .type = "CALLS", + .properties_json = "{\"confidence\":0.9}"}; + cbm_edge_t stale_edge = {.project = (char *)project, + .source_id = caller_id, + .target_id = stale_id, + .type = "CALLS", + .properties_json = "{\"stale\":true}"}; + cbm_edge_t recomputed_edge = {.project = (char *)project, + .source_id = caller_id, + .target_id = target_id, + .type = CBM_PIPELINE_EDGE_SIMILAR_TO, + .properties_json = "{\"score\":1.0}"}; + ASSERT_GT(cbm_store_insert_edge(s, &call_edge), 0); + ASSERT_GT(cbm_store_insert_edge(s, &stale_edge), 0); + ASSERT_GT(cbm_store_insert_edge(s, &recomputed_edge), 0); + ASSERT_EQ(cbm_store_rebuild_file_delta_owners(s, project, 1), CBM_STORE_OK); + + cbm_gbuf_t *gb = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "Handle", target_qn, target_rel, 3, 7, + "{\"is_exported\":true}"), + 0); + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(gb, project, target_rel, 1, &delta), + CBM_STORE_OK); + ASSERT_EQ(delta.delta.edge_count, 0); - /* Should have Class nodes (database, server sections from TOML) */ - cbm_node_t *classes = NULL; - int cc = 0; - cbm_store_find_nodes_by_label(s, proj, "Class", &classes, &cc); - ASSERT_GT(cc, 0); - if (classes) - cbm_store_free_nodes(classes, cc); + int added = -1; + ASSERT_EQ(cbm_pipeline_file_delta_add_preserved_inbound_edges(s, &delta, &added), + CBM_STORE_OK); + ASSERT_EQ(added, 1); + ASSERT_EQ(delta.delta.edge_count, 1); + const cbm_store_delta_edge_t *preserved = + pipeline_delta_find_edge_by_qn(&delta, caller_qn, target_qn, "CALLS"); + ASSERT_NOT_NULL(preserved); + ASSERT_STR_EQ(preserved->properties_json, "{\"confidence\":0.9}"); + ASSERT_NULL(pipeline_delta_find_edge_by_qn(&delta, caller_qn, stale_qn, "CALLS")); + ASSERT_NULL( + pipeline_delta_find_edge_by_qn(&delta, caller_qn, target_qn, CBM_PIPELINE_EDGE_SIMILAR_TO)); + + added = -1; + ASSERT_EQ(cbm_pipeline_file_delta_add_preserved_inbound_edges(s, &delta, &added), + CBM_STORE_OK); + ASSERT_EQ(added, 0); + ASSERT_EQ(delta.delta.edge_count, 1); - /* Should have Variable nodes from config files */ - cbm_node_t *vars = NULL; - int vc = 0; - cbm_store_find_nodes_by_label(s, proj, "Variable", &vars, &vc); - ASSERT_GT(vc, 0); - if (vars) - cbm_store_free_nodes(vars, vc); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(gb); + cbm_store_close(s); + PASS(); +} - /* Should have Function nodes from Go code */ - cbm_node_t *funcs = NULL; - int fc = 0; - cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc); - ASSERT_GT(fc, 0); - if (funcs) - cbm_store_free_nodes(funcs, fc); +TEST(pipeline_file_delta_preserves_sibling_named_imports_to_shared_target) { + const char *project = "test"; + const char *target_rel = "target.py"; + const char *caller_rel = "consumer.py"; + const char *target_qn = "test.target.Service.openapi"; + const char *caller_qn = "test.consumer.py.__file__"; + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + int64_t target_id = + pipeline_delta_seed_existing_ownership_id(s, project, target_rel, target_qn); + int64_t caller_id = + pipeline_delta_seed_existing_ownership_id(s, project, caller_rel, caller_qn); + ASSERT_GT(target_id, 0); + ASSERT_GT(caller_id, 0); + + cbm_edge_t first = {.project = (char *)project, + .source_id = caller_id, + .target_id = target_id, + .type = "IMPORTS", + .properties_json = "{\"local_name\":\"METHODS_WITH_BODY\"}"}; + cbm_edge_t second = {.project = (char *)project, + .source_id = caller_id, + .target_id = target_id, + .type = "IMPORTS", + .properties_json = "{\"local_name\":\"REF_PREFIX\"}"}; + ASSERT_GT(cbm_store_insert_edge(s, &first), 0); + ASSERT_GT(cbm_store_insert_edge(s, &second), 0); + ASSERT_EQ(cbm_store_rebuild_file_delta_owners(s, project, 1), CBM_STORE_OK); + + cbm_gbuf_t *gb = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Method", "openapi", target_qn, target_rel, 3, 7, + "{\"is_exported\":true}"), + 0); + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(gb, project, target_rel, 1, &delta), + CBM_STORE_OK); + int added = -1; + ASSERT_EQ(cbm_pipeline_file_delta_add_preserved_inbound_edges(s, &delta, &added), + CBM_STORE_OK); + ASSERT_EQ(added, 2); + ASSERT_EQ(delta.delta.edge_count, 2); + ASSERT_TRUE(strstr(delta.delta.edges[0].properties_json, "METHODS_WITH_BODY") != NULL || + strstr(delta.delta.edges[1].properties_json, "METHODS_WITH_BODY") != NULL); + ASSERT_TRUE(strstr(delta.delta.edges[0].properties_json, "REF_PREFIX") != NULL || + strstr(delta.delta.edges[1].properties_json, "REF_PREFIX") != NULL); + + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(gb); cbm_store_close(s); - cbm_pipeline_free(p); - teardown_lang_repo(); PASS(); } -TEST(enrichment_split_camel_case) { - char *parts[8]; - int n; - n = cbm_split_camel_case("GetMapping", parts, 8); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(parts[0], "Get"); - ASSERT_STR_EQ(parts[1], "Mapping"); - for (int i = 0; i < n; i++) - free(parts[i]); +TEST(pipeline_file_delta_descriptor_marks_unsupported_edges) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + int64_t run_id = + cbm_gbuf_upsert_node(gb, "Function", "Run", "proj.main.Run", "main.go", 3, 5, "{}"); + ASSERT_GT(run_id, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, run_id, 9999, "CALLS", "{}"), 0); - n = cbm_split_camel_case("getMessage", parts, 8); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(parts[0], "get"); - ASSERT_STR_EQ(parts[1], "Message"); - for (int i = 0; i < n; i++) - free(parts[i]); + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(gb, "proj", "main.go", 8, &delta), + CBM_STORE_OK); + ASSERT_EQ(delta.unsupported_edge_count, 1); + ASSERT_EQ(delta.delta.node_count, 1); + ASSERT_EQ(delta.delta.edge_count, 0); - n = cbm_split_camel_case("cache", parts, 8); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(parts[0], "cache"); - for (int i = 0; i < n; i++) - free(parts[i]); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(gb); + PASS(); +} - n = cbm_split_camel_case("HTMLParser", parts, 8); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(parts[0], "HTMLParser"); - for (int i = 0; i < n; i++) - free(parts[i]); +TEST(pipeline_file_delta_metadata_from_file) { + enum { + PIPELINE_DELTA_META_GENERATION_FIRST = 11, + PIPELINE_DELTA_META_GENERATION_SECOND = 12, + }; + char *tmp = th_mktempdir("cbm_delta_meta"); + ASSERT_NOT_NULL(tmp); + const char *path = TH_PATH(tmp, "main.go"); + const char *first_content = "package main\nfunc Run() {}\n"; + ASSERT_EQ(th_write_file(path, first_content), 0); + + cbm_file_info_t file = { + .path = (char *)path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(first_content), + }; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", + .rel_path = "main.go", + .generation = PIPELINE_DELTA_META_GENERATION_FIRST}}; + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&delta, &file), CBM_STORE_OK); + ASSERT(delta.delta.file_hash == &delta.file_hash); + ASSERT(delta.delta.file_state == &delta.file_state); + ASSERT_STR_EQ(delta.file_hash.project, "test"); + ASSERT_STR_EQ(delta.file_hash.rel_path, "main.go"); + ASSERT_STR_EQ(delta.file_hash.sha256, ""); + ASSERT_EQ(delta.file_hash.size, (int64_t)strlen(first_content)); + ASSERT_STR_EQ(delta.file_state.project, "test"); + ASSERT_STR_EQ(delta.file_state.rel_path, "main.go"); + ASSERT_EQ(delta.file_state.size, (int64_t)strlen(first_content)); + ASSERT_STR_EQ(delta.file_state.language, "Go"); + ASSERT_EQ(delta.file_state.generation, PIPELINE_DELTA_META_GENERATION_FIRST); + ASSERT_NOT_NULL(delta.file_state.content_hash); + ASSERT_EQ((int)strlen(delta.file_state.content_hash), CBM_SZ_16); + ASSERT_NOT_NULL(delta.file_state.indexed_at); + ASSERT_NOT_NULL(strchr(delta.file_state.indexed_at, 'T')); + char first_hash[CBM_SZ_32]; + snprintf(first_hash, sizeof(first_hash), "%s", delta.file_state.content_hash); + + const char *second_content = "package main\nfunc Run() { println(\"changed\") }\n"; + ASSERT_EQ(th_write_file(path, second_content), 0); + cbm_pipeline_file_delta_t changed = { + .delta = {.project = "test", + .rel_path = "main.go", + .generation = PIPELINE_DELTA_META_GENERATION_SECOND}}; + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&changed, &file), CBM_STORE_OK); + ASSERT_NEQ(strcmp(first_hash, changed.file_state.content_hash), 0); + ASSERT_EQ(changed.file_state.generation, PIPELINE_DELTA_META_GENERATION_SECOND); + + th_cleanup(tmp); + PASS(); +} - n = cbm_split_camel_case("", parts, 8); - ASSERT_EQ(n, 0); +TEST(pipeline_file_delta_metadata_accepts_effective_fingerprint) { + enum { PIPELINE_DELTA_META_GENERATION = 13 }; + char effective_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_format_file_delta_pass_fingerprint( + effective_fingerprint, sizeof(effective_fingerprint), CBM_MODE_FULL, 0.7, 0.25, + 0.75, 0.3, CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS, 0.6), + CBM_STORE_OK); + char *tmp = th_mktempdir("cbm_delta_meta_fingerprint"); + ASSERT_NOT_NULL(tmp); + const char *path = TH_PATH(tmp, "main.go"); + const char *content = "package main\nfunc Run() { println(\"fingerprint\") }\n"; + ASSERT_EQ(th_write_file(path, content), 0); + + cbm_file_info_t file = { + .path = (char *)path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(content), + }; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", + .rel_path = "main.go", + .generation = PIPELINE_DELTA_META_GENERATION}}; + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata_with_fingerprint( + &delta, &file, effective_fingerprint), + CBM_STORE_OK); + ASSERT_STR_EQ(delta.file_state.pass_fingerprint, effective_fingerprint); + + th_cleanup(tmp); PASS(); } -TEST(enrichment_tokenize_decorator) { - char *tokens[16]; - int n; +TEST(pipeline_file_delta_stamp_generation_updates_metadata) { + enum { PIPELINE_DELTA_STAMP_GENERATION = 21 }; + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "main.go"}}; + cbm_file_hash_t hash = {.project = "test", .rel_path = "main.go", .sha256 = ""}; + delta.file_state = (cbm_file_state_t){.project = "test", + .rel_path = "main.go", + .content_hash = "test-content", + .indexed_at = "2026-07-01T00:00:00Z"}; + delta.delta.file_hash = &hash; + delta.delta.file_state = &delta.file_state; - n = cbm_tokenize_decorator("@Override", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "override"); - for (int i = 0; i < n; i++) - free(tokens[i]); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, 0), CBM_STORE_ERR); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, PIPELINE_DELTA_STAMP_GENERATION), + CBM_STORE_OK); + ASSERT_EQ(delta.delta.generation, PIPELINE_DELTA_STAMP_GENERATION); + ASSERT_EQ(delta.file_state.generation, PIPELINE_DELTA_STAMP_GENERATION); + ASSERT_EQ(delta.delta.file_state->generation, PIPELINE_DELTA_STAMP_GENERATION); - n = cbm_tokenize_decorator("@Deprecated", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "deprecated"); - for (int i = 0; i < n; i++) - free(tokens[i]); + PASS(); +} - n = cbm_tokenize_decorator("@Test", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "test"); - for (int i = 0; i < n; i++) - free(tokens[i]); +TEST(pipeline_content_hash_helper_matches_file_delta_metadata) { + enum { PIPELINE_DELTA_META_GENERATION = 14 }; + char *tmp = th_mktempdir("cbm_delta_hash"); + ASSERT_NOT_NULL(tmp); + const char *path = TH_PATH(tmp, "main.go"); + const char *content = "package main\nfunc Run() { println(\"hash\") }\n"; + ASSERT_EQ(th_write_file(path, content), 0); - n = cbm_tokenize_decorator("@login_required", tokens, 16); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(tokens[0], "login"); - ASSERT_STR_EQ(tokens[1], "required"); - for (int i = 0; i < n; i++) - free(tokens[i]); + char expected_hash[CBM_SZ_32]; + ASSERT_EQ(cbm_pipeline_content_hash_file(path, expected_hash, sizeof(expected_hash)), + CBM_STORE_OK); - n = cbm_tokenize_decorator("@cache", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "cache"); - for (int i = 0; i < n; i++) - free(tokens[i]); + cbm_file_info_t file = { + .path = (char *)path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(content), + }; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", + .rel_path = "main.go", + .generation = PIPELINE_DELTA_META_GENERATION}}; + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&delta, &file), CBM_STORE_OK); + ASSERT_STR_EQ(delta.file_state.content_hash, expected_hash); + ASSERT_EQ((int)strlen(expected_hash), CBM_SZ_16); + + th_cleanup(tmp); + PASS(); +} - n = cbm_tokenize_decorator("@pytest.fixture", tokens, 16); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(tokens[0], "pytest"); - ASSERT_STR_EQ(tokens[1], "fixture"); - for (int i = 0; i < n; i++) - free(tokens[i]); +TEST(pipeline_file_state_persist_helper_writes_hash_metadata) { + enum { PIPELINE_FILE_STATE_GENERATION = 14 }; + char *tmp = th_mktempdir("cbm_file_state_persist"); + ASSERT_NOT_NULL(tmp); + const char *go_path = TH_PATH(tmp, "main.go"); + const char *py_path = TH_PATH(tmp, "worker.py"); + const char *go_content = "package main\nfunc Run() { println(\"persist\") }\n"; + const char *py_content = "def run():\n return 'persist'\n"; + ASSERT_EQ(th_write_file(go_path, go_content), 0); + ASSERT_EQ(th_write_file(py_path, py_content), 0); + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", tmp), CBM_STORE_OK); + + cbm_file_info_t files[2] = { + {.path = (char *)go_path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(go_content)}, + {.path = (char *)py_path, + .rel_path = "worker.py", + .language = CBM_LANG_PYTHON, + .size = (int64_t)strlen(py_content)}, + }; + ASSERT_EQ(cbm_pipeline_persist_file_states(s, "test", files, 2, PIPELINE_FILE_STATE_GENERATION, + "test-pass"), + CBM_STORE_OK); - /* "get" is stopword → only "mapping" */ - n = cbm_tokenize_decorator("@GetMapping(\"/api\")", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "mapping"); - for (int i = 0; i < n; i++) - free(tokens[i]); + char expected_go_hash[CBM_SZ_32]; + char expected_py_hash[CBM_SZ_32]; + ASSERT_EQ(cbm_pipeline_content_hash_file(go_path, expected_go_hash, sizeof(expected_go_hash)), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_content_hash_file(py_path, expected_py_hash, sizeof(expected_py_hash)), + CBM_STORE_OK); - /* "post" passes, "mapping" passes */ - n = cbm_tokenize_decorator("@PostMapping(\"/api\")", tokens, 16); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(tokens[0], "post"); - ASSERT_STR_EQ(tokens[1], "mapping"); - for (int i = 0; i < n; i++) - free(tokens[i]); + cbm_file_state_t go_state = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &go_state), CBM_STORE_OK); + ASSERT_STR_EQ(go_state.content_hash, expected_go_hash); + ASSERT_STR_EQ(go_state.language, "Go"); + ASSERT_STR_EQ(go_state.pass_fingerprint, "test-pass"); + ASSERT_EQ(go_state.size, (int64_t)strlen(go_content)); + ASSERT_EQ(go_state.generation, PIPELINE_FILE_STATE_GENERATION); + ASSERT_NOT_NULL(go_state.indexed_at); + ASSERT_NOT_NULL(strchr(go_state.indexed_at, 'T')); + cbm_store_file_state_free_fields(&go_state); + + cbm_file_state_t py_state = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "worker.py", &py_state), CBM_STORE_OK); + ASSERT_STR_EQ(py_state.content_hash, expected_py_hash); + ASSERT_STR_EQ(py_state.language, "Python"); + ASSERT_STR_EQ(py_state.pass_fingerprint, "test-pass"); + ASSERT_EQ(py_state.size, (int64_t)strlen(py_content)); + ASSERT_EQ(py_state.generation, PIPELINE_FILE_STATE_GENERATION); + cbm_store_file_state_free_fields(&py_state); - n = cbm_tokenize_decorator("@Transactional", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "transactional"); - for (int i = 0; i < n; i++) - free(tokens[i]); + cbm_store_close(s); + th_cleanup(tmp); + PASS(); +} - n = cbm_tokenize_decorator("@MessageMapping(\"/chat\")", tokens, 16); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(tokens[0], "message"); - ASSERT_STR_EQ(tokens[1], "mapping"); - for (int i = 0; i < n; i++) - free(tokens[i]); +TEST(pipeline_file_state_current_check_rejects_stale_pass_fingerprint) { + enum { PIPELINE_FILE_STATE_GENERATION = 15 }; + char *tmp = th_mktempdir("cbm_file_state_current_pass"); + ASSERT_NOT_NULL(tmp); + const char *path = TH_PATH(tmp, "main.go"); + const char *content = "package main\nfunc Run() { println(\"current\") }\n"; + ASSERT_EQ(th_write_file(path, content), 0); - /* Rust-style #[test] */ - n = cbm_tokenize_decorator("#[test]", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "test"); - for (int i = 0; i < n; i++) - free(tokens[i]); + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", tmp), CBM_STORE_OK); - /* #[derive(Debug)] */ - n = cbm_tokenize_decorator("#[derive(Debug)]", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "derive"); - for (int i = 0; i < n; i++) - free(tokens[i]); + cbm_file_info_t file = {.path = (char *)path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(content)}; + ASSERT_TRUE(cbm_pipeline_file_state_is_current_or_legacy( + s, "test", &file, cbm_pipeline_file_delta_pass_fingerprint())); - /* Both "app" and "get" are stopwords → empty */ - n = cbm_tokenize_decorator("@app.get(\"/api\")", tokens, 16); - ASSERT_EQ(n, 0); + ASSERT_EQ(cbm_pipeline_persist_file_states(s, "test", &file, 1, + PIPELINE_FILE_STATE_GENERATION, "old-pass"), + CBM_STORE_OK); + ASSERT_FALSE(cbm_pipeline_file_state_is_current_or_legacy( + s, "test", &file, cbm_pipeline_file_delta_pass_fingerprint())); - /* "router" is stopword, "post" passes */ - n = cbm_tokenize_decorator("@router.post(\"/api\")", tokens, 16); - ASSERT_EQ(n, 1); - ASSERT_STR_EQ(tokens[0], "post"); - for (int i = 0; i < n; i++) - free(tokens[i]); + ASSERT_EQ(cbm_pipeline_persist_file_states(s, "test", &file, 1, + PIPELINE_FILE_STATE_GENERATION + 1, NULL), + CBM_STORE_OK); + ASSERT_TRUE(cbm_pipeline_file_state_is_current_or_legacy( + s, "test", &file, cbm_pipeline_file_delta_pass_fingerprint())); - /* Too short after filtering */ - n = cbm_tokenize_decorator("@x", tokens, 16); - ASSERT_EQ(n, 0); + cbm_store_close(s); + th_cleanup(tmp); + PASS(); +} - /* Empty */ - n = cbm_tokenize_decorator("", tokens, 16); - ASSERT_EQ(n, 0); +TEST(pipeline_pass_fingerprint_includes_effective_mode_and_thresholds) { + char full_default[CBM_SZ_256]; + char full_tuned[CBM_SZ_256]; + char full_tuned_again[CBM_SZ_256]; + char full_coupling_budget_tuned[CBM_SZ_256]; + char fast_default[CBM_SZ_256]; + char capabilities_disabled[CBM_SZ_256]; - n = cbm_tokenize_decorator("@click.command", tokens, 16); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(tokens[0], "click"); - ASSERT_STR_EQ(tokens[1], "command"); - for (int i = 0; i < n; i++) - free(tokens[i]); + cbm_pipeline_t *full = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + cbm_pipeline_t *fast = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FAST); + ASSERT_NOT_NULL(full); + ASSERT_NOT_NULL(fast); - n = cbm_tokenize_decorator("@celery.task", tokens, 16); - ASSERT_EQ(n, 2); - ASSERT_STR_EQ(tokens[0], "celery"); - ASSERT_STR_EQ(tokens[1], "task"); - for (int i = 0; i < n; i++) - free(tokens[i]); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(full, full_default, sizeof(full_default)), + CBM_STORE_OK); + cbm_pipeline_set_similarity_threshold(full, 0.7); + cbm_pipeline_set_httplink_min_confidence(full, 0.25); + cbm_pipeline_set_semantic_threshold(full, 0.75); + cbm_pipeline_set_githistory_min_coupling(full, 0.3); + cbm_pipeline_set_lsp_confidence_floor(full, 0.6); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(full, full_tuned, sizeof(full_tuned)), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(full, full_tuned_again, + sizeof(full_tuned_again)), + CBM_STORE_OK); + cbm_pipeline_set_githistory_max_couplings(full, CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS + 1); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(full, full_coupling_budget_tuned, + sizeof(full_coupling_budget_tuned)), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(fast, fast_default, sizeof(fast_default)), + CBM_STORE_OK); + cbm_pipeline_set_similarity_enabled(full, false); + cbm_pipeline_set_semantic_edges_enabled(full, false); + cbm_pipeline_set_githistory_enabled(full, false); + cbm_pipeline_set_httplinks_enabled(full, false); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(full, capabilities_disabled, + sizeof(capabilities_disabled)), + CBM_STORE_OK); + + ASSERT_NEQ(strcmp(full_default, full_tuned), 0); + ASSERT_STR_EQ(full_tuned, full_tuned_again); + ASSERT_NEQ(strcmp(full_tuned, full_coupling_budget_tuned), 0); + ASSERT_NEQ(strcmp(full_default, fast_default), 0); + ASSERT_NEQ(strcmp(full_coupling_budget_tuned, capabilities_disabled), 0); + + cbm_pipeline_free(full); + cbm_pipeline_free(fast); PASS(); } -/* ── Decorator tags integration tests (enrichment_test.go ports) ─ */ +TEST(pipeline_file_state_current_check_rejects_stale_config_fingerprint) { + enum { PIPELINE_FILE_STATE_GENERATION = 16 }; + char *tmp = th_mktempdir("cbm_file_state_current_config"); + ASSERT_NOT_NULL(tmp); + const char *path = TH_PATH(tmp, "main.go"); + const char *content = "package main\nfunc Run() { println(\"config\") }\n"; + ASSERT_EQ(th_write_file(path, content), 0); + + char old_fingerprint[CBM_SZ_256]; + char current_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_format_file_delta_pass_fingerprint( + old_fingerprint, sizeof(old_fingerprint), CBM_MODE_FULL, 0.7, 0.25, 0.75, 0.3, + CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS, 0.6), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_format_file_delta_pass_fingerprint( + current_fingerprint, sizeof(current_fingerprint), CBM_MODE_FULL, 0.8, 0.25, 0.75, + 0.3, CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS, 0.6), + CBM_STORE_OK); + ASSERT_NEQ(strcmp(old_fingerprint, current_fingerprint), 0); -/* Helper: check if a node's properties_json contains a specific decorator_tag */ -static bool has_decorator_tag(const char *properties_json, const char *tag) { - if (!properties_json || !tag) - return false; - yyjson_doc *doc = yyjson_read(properties_json, strlen(properties_json), 0); - if (!doc) - return false; - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_val *tags = yyjson_obj_get(root, "decorator_tags"); - if (!tags || !yyjson_is_arr(tags)) { - yyjson_doc_free(doc); - return false; - } - yyjson_val *item; - yyjson_arr_iter iter; - yyjson_arr_iter_init(tags, &iter); - while ((item = yyjson_arr_iter_next(&iter))) { - if (yyjson_is_str(item) && strcmp(yyjson_get_str(item), tag) == 0) { - yyjson_doc_free(doc); - return true; - } - } - yyjson_doc_free(doc); - return false; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", tmp), CBM_STORE_OK); + + cbm_file_info_t file = {.path = (char *)path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(content)}; + ASSERT_EQ(cbm_pipeline_persist_file_states(s, "test", &file, 1, + PIPELINE_FILE_STATE_GENERATION, old_fingerprint), + CBM_STORE_OK); + ASSERT_FALSE(cbm_pipeline_file_state_is_current_or_legacy( + s, "test", &file, current_fingerprint)); + ASSERT_TRUE(cbm_pipeline_file_state_is_current_or_legacy(s, "test", &file, old_fingerprint)); + + cbm_store_close(s); + th_cleanup(tmp); + PASS(); } -TEST(decorator_tags_python_auto_discovery) { - /* Port of TestDecoratorTagAutoDiscovery: - * Python file with repeated decorators (@login_required on 2 funcs, - * @cache on 2 funcs, @unique_helper on 1 func). - * Words on 2+ nodes become tags; unique words do not. */ - const char *files[] = {"views.py"}; - const char *contents[] = {"from functools import cache\n\n" - "@login_required\n" - "def list_orders():\n" - " pass\n\n" - "@login_required\n" - "def get_order():\n" - " pass\n\n" - "@cache\n" - "def compute_total():\n" - " pass\n\n" - "@cache\n" - "def compute_tax():\n" - " pass\n\n" - "@unique_helper\n" - "def special():\n" - " pass\n"}; - if (setup_lang_repo(files, contents, 1) != 0) - FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); +TEST(pipeline_file_state_persist_helper_rolls_back_on_failure) { + char *tmp = th_mktempdir("cbm_file_state_persist_fail"); + ASSERT_NOT_NULL(tmp); + const char *path = TH_PATH(tmp, "main.go"); + const char *content = "package main\nfunc Run() {}\n"; + ASSERT_EQ(th_write_file(path, content), 0); - cbm_store_t *s = cbm_store_open_path(db); + cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); - const char *proj = cbm_pipeline_project_name(p); + ASSERT_EQ(cbm_store_upsert_project(s, "test", tmp), CBM_STORE_OK); + + cbm_file_info_t files[2] = { + {.path = (char *)path, + .rel_path = "main.go", + .language = CBM_LANG_GO, + .size = (int64_t)strlen(content)}, + {.path = (char *)TH_PATH(tmp, "missing.py"), + .rel_path = "missing.py", + .language = CBM_LANG_PYTHON, + .size = 0}, + }; + ASSERT_EQ(cbm_pipeline_persist_file_states(s, "test", files, 2, + CBM_PIPELINE_COMPAT_GENERATION, "test-pass"), + CBM_STORE_ERR); - /* Find functions by name and check decorator_tags */ - cbm_node_t *funcs = NULL; - int fc = 0; - cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc); + cbm_file_state_t state = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &state), CBM_STORE_NOT_FOUND); - /* Build name→properties_json map */ - const char *list_orders_props = NULL; - const char *get_order_props = NULL; - const char *compute_total_props = NULL; - const char *compute_tax_props = NULL; - const char *special_props = NULL; - for (int i = 0; i < fc; i++) { - if (strcmp(funcs[i].name, "list_orders") == 0) - list_orders_props = funcs[i].properties_json; - else if (strcmp(funcs[i].name, "get_order") == 0) - get_order_props = funcs[i].properties_json; - else if (strcmp(funcs[i].name, "compute_total") == 0) - compute_total_props = funcs[i].properties_json; - else if (strcmp(funcs[i].name, "compute_tax") == 0) - compute_tax_props = funcs[i].properties_json; - else if (strcmp(funcs[i].name, "special") == 0) - special_props = funcs[i].properties_json; - } + cbm_store_close(s); + th_cleanup(tmp); + PASS(); +} - /* "login" and "required" appear on 2 nodes → should be tags */ - ASSERT_TRUE(has_decorator_tag(list_orders_props, "login")); - ASSERT_TRUE(has_decorator_tag(list_orders_props, "required")); - ASSERT_TRUE(has_decorator_tag(get_order_props, "login")); - ASSERT_TRUE(has_decorator_tag(get_order_props, "required")); +TEST(pipeline_file_delta_plan_candidate_from_frontier) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "lib.go", "test.lib.Old"), + CBM_STORE_OK); - /* "cache" appears on 2 nodes → should be a tag */ - ASSERT_TRUE(has_decorator_tag(compute_total_props, "cache")); - ASSERT_TRUE(has_decorator_tag(compute_tax_props, "cache")); + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.lib.Value", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", .rel_path = "lib.go", .exports = exports, .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(plan.affected_count, 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, "lib.go"), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - /* "unique" and "helper" appear on only 1 node → should NOT be tags */ - ASSERT_FALSE(has_decorator_tag(special_props, "unique")); - ASSERT_FALSE(has_decorator_tag(special_props, "helper")); +TEST(pipeline_file_delta_apply_falls_back_on_publish_error) { + enum { PIPELINE_DELTA_APPLY_ONE = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "lib.go", "test.lib.Old"), + CBM_STORE_OK); - if (funcs) - cbm_store_free_nodes(funcs, fc); + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = "Value", + .qualified_name = "test.lib.Value", + .file_path = "lib.go", + .properties_json = "{}"}}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.lib.Value", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", + .rel_path = "lib.go", + .nodes = nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_DELTA_APPLY_ONE, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "publish_error"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, "test", "test.lib.Value"), 0); + + cbm_pipeline_file_delta_plan_free(&plan); cbm_store_close(s); - cbm_pipeline_free(p); - teardown_lang_repo(); PASS(); } -TEST(decorator_tags_java_class_methods) { - /* Port of TestDecoratorTagJavaClassMethods: - * Java class with @GetMapping, @PostMapping, @Transactional annotations. - * "mapping" appears on all 4 → tag. "post" on 2 → tag. */ - const char *files[] = {"Controller.java"}; - const char *contents[] = {"class OwnerController {\n" - " @GetMapping(\"/owners\")\n" - " public void listOwners() {}\n\n" - " @GetMapping(\"/owners/{id}\")\n" - " public void showOwner() {}\n\n" - " @PostMapping(\"/owners\")\n" - " public void createOwner() {}\n\n" - " @Transactional\n" - " @PostMapping(\"/owners/{id}\")\n" - " public void updateOwner() {}\n" - "}\n"}; - if (setup_lang_repo(files, contents, 1) != 0) - FAIL("tmpdir"); - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - - cbm_store_t *s = cbm_store_open_path(db); +TEST(pipeline_file_delta_apply_falls_back_without_generation) { + enum { PIPELINE_DELTA_APPLY_ONE = 1 }; + cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); - const char *proj = cbm_pipeline_project_name(p); - - /* Find methods */ - cbm_node_t *methods = NULL; - int mc = 0; - cbm_store_find_nodes_by_label(s, proj, "Method", &methods, &mc); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "lib.go", "test.lib.Old"), + CBM_STORE_OK); - /* "mapping" appears on all 4 methods → should be a tag */ - for (int i = 0; i < mc; i++) { - if (strcmp(methods[i].name, "listOwners") == 0 || - strcmp(methods[i].name, "showOwner") == 0 || - strcmp(methods[i].name, "createOwner") == 0 || - strcmp(methods[i].name, "updateOwner") == 0) { - ASSERT_TRUE(has_decorator_tag(methods[i].properties_json, "mapping")); - } - } + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.lib.Value", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", .rel_path = "lib.go", .exports = exports, .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + delta.delta.generation = 0; + delta.file_state.generation = 0; + + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_DELTA_APPLY_ONE, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "missing_generation"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, "test", "test.lib.Value"), 0); - /* "post" appears on createOwner + updateOwner → should be a tag */ - for (int i = 0; i < mc; i++) { - if (strcmp(methods[i].name, "createOwner") == 0 || - strcmp(methods[i].name, "updateOwner") == 0) { - ASSERT_TRUE(has_decorator_tag(methods[i].properties_json, "post")); - } - } + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - /* "transactional" appears on only 1 method → should NOT be a tag */ - for (int i = 0; i < mc; i++) { - if (strcmp(methods[i].name, "updateOwner") == 0) { - ASSERT_FALSE(has_decorator_tag(methods[i].properties_json, "transactional")); - } - } +TEST(pipeline_file_delta_apply_succeeds_after_generation_stamp) { + enum { PIPELINE_DELTA_APPLY_ONE = 1 }; + const char *project = "test"; + const char *rel_path = "lib.go"; + const char *old_qn = "test.lib.Old"; + const char *new_qn = "test.lib.Value"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, rel_path, old_qn), CBM_STORE_OK); + + cbm_node_t nodes[1] = {{.project = (char *)project, + .label = "Function", + .name = "Value", + .qualified_name = (char *)new_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = new_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = rel_path, + .nodes = nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + delta.delta.generation = 0; + delta.file_state.generation = 0; + state.generation = 0; + + cbm_pipeline_file_delta_plan_t preflight_plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &preflight_plan), + CBM_STORE_OK); + ASSERT_EQ(preflight_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + cbm_pipeline_file_delta_plan_free(&preflight_plan); - if (methods) - cbm_store_free_nodes(methods, mc); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, 0); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, generation), CBM_STORE_OK); + ASSERT(delta.delta.file_state == &delta.file_state); + ASSERT_EQ(delta.file_state.generation, generation); + ASSERT_EQ(state.generation, 0); + + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t apply_plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_DELTA_APPLY_ONE, + CBM_SZ_4, &apply_plan), + CBM_STORE_OK); + ASSERT_EQ(apply_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_qn), 1); + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, project, rel_path, &got), CBM_STORE_OK); + ASSERT_EQ(got.generation, generation); + cbm_store_file_state_free_fields(&got); + + cbm_pipeline_file_delta_plan_free(&apply_plan); cbm_store_close(s); - cbm_pipeline_free(p); - teardown_lang_repo(); PASS(); } -/* ── Compile commands helpers (pass_compile_commands.c) ────────── */ +TEST(pipeline_file_delta_apply_inserts_new_file_without_existing_ownership) { + enum { PIPELINE_NEW_FILE_DELTA_COUNT = 1 }; + const char *project = "test"; + const char *rel_path = "pkg/new.go"; + const char *new_qn = "test.pkg.new.Value"; -TEST(compile_commands_split_command) { - char *args[16]; - int n; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_project_node(s, project), CBM_STORE_OK); + + char *folder_qn = cbm_pipeline_fqn_folder(project, "pkg"); + ASSERT_NOT_NULL(folder_qn); + cbm_node_t folder = {.project = (char *)project, + .label = "Folder", + .name = "pkg", + .qualified_name = folder_qn, + .file_path = "pkg", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &folder), CBM_STORE_NO_NODE_ID); + + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(scratch); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Project", project, project, NULL, 0, 0, "{}"), 0); + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, project, rel_path, NULL), 0); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Function", "Value", new_qn, rel_path, 1, 1, + "{\"is_exported\":true}"), + 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, rel_path, 0, &delta), + CBM_STORE_OK); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + delta.delta.generation = 0; + delta.file_state.generation = 0; + + cbm_pipeline_file_delta_plan_t preflight_plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &preflight_plan), + CBM_STORE_OK); + ASSERT_EQ(preflight_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_plan_contains_path(&preflight_plan, rel_path), 1); + ASSERT_EQ(preflight_plan.affected_count, 1); + cbm_pipeline_file_delta_plan_free(&preflight_plan); - n = cbm_split_command("gcc -c main.c", args, 16); - ASSERT_EQ(n, 3); - ASSERT_STR_EQ(args[0], "gcc"); - ASSERT_STR_EQ(args[1], "-c"); - ASSERT_STR_EQ(args[2], "main.c"); - for (int i = 0; i < n; i++) - free(args[i]); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, 0); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, generation), CBM_STORE_OK); - n = cbm_split_command("gcc -DFOO=\"bar baz\" -c main.c", args, 16); - ASSERT_EQ(n, 4); - for (int i = 0; i < n; i++) - free(args[i]); + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t apply_plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_NEW_FILE_DELTA_COUNT, + CBM_SZ_4, &apply_plan), + CBM_STORE_OK); + ASSERT_EQ(apply_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_qn), 1); - n = cbm_split_command("g++ -I/usr/include -std=c++17 -o out -c in.cpp", args, 16); - ASSERT_EQ(n, 7); - for (int i = 0; i < n; i++) - free(args[i]); + int node_owners = 0; + int edge_owners = 0; + ASSERT_EQ(cbm_store_count_file_delta_owners(s, project, rel_path, &node_owners, + &edge_owners), + CBM_STORE_OK); + ASSERT_GT(node_owners, 0); + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, project, rel_path, &got), CBM_STORE_OK); + ASSERT_EQ(got.generation, generation); + cbm_store_file_state_free_fields(&got); + + cbm_pipeline_file_delta_plan_free(&apply_plan); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(folder_qn); + cbm_store_close(s); PASS(); } -TEST(compile_commands_extract_flags) { - const char *args[] = {"g++", "-I", "/abs/include", "-I/rel/include", "-isystem", - "/sys/include", "-DFOO", "-DBAR=42", "-std=c++20", "-O2", - "-Wall", "-c", "main.cpp"}; +TEST(pipeline_file_delta_apply_falls_back_on_new_file_importer_frontier) { + enum { PIPELINE_NEW_IMPORTER_DELTA_COUNT = 1 }; + const char *project = "test"; + const char *new_rel = "pkg/new.go"; + const char *main_rel = "main.go"; + const char *new_qn = "test.pkg.new.Value"; - cbm_compile_flags_t *f = cbm_extract_flags(args, 13, "/project"); - ASSERT_NOT_NULL(f); - ASSERT_EQ(f->include_count, 3); - ASSERT_EQ(f->define_count, 2); - ASSERT_STR_EQ(f->standard, "c++20"); - cbm_compile_flags_free(f); + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, main_rel, "test.main.Main"), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, main_rel, "test.pkg.new", "Value", + new_qn, 1), + CBM_STORE_OK); + + cbm_node_t nodes[1] = {{.project = (char *)project, + .label = "Function", + .name = "Value", + .qualified_name = (char *)new_qn, + .file_path = (char *)new_rel, + .properties_json = "{\"is_exported\":true}"}}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = new_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = new_rel, + .nodes = nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, + PIPELINE_NEW_IMPORTER_DELTA_COUNT, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "frontier_requires_batch"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_qn), 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(compile_commands_parse_json) { - const char *json = "[\n" - " {\n" - " \"directory\": \"/home/user/project/build\",\n" - " \"command\": \"gcc -I/home/user/project/include " - "-I/home/user/project/src -DDEBUG=1 -DVERSION=\\\"1.0\\\" " - "-std=c11 -o main.o -c /home/user/project/src/main.c\",\n" - " \"file\": \"/home/user/project/src/main.c\"\n" - " },\n" - " {\n" - " \"directory\": \"/home/user/project/build\",\n" - " \"arguments\": [\"g++\", \"-I/home/user/project/include\", " - "\"-isystem\", \"/home/user/project/third_party\", " - "\"-DUSE_SSL\", \"-std=c++17\", \"-c\", " - "\"/home/user/project/src/server.cpp\"],\n" - " \"file\": \"/home/user/project/src/server.cpp\"\n" - " },\n" - " {\n" - " \"directory\": \"/home/user/project/build\",\n" - " \"command\": \"gcc -c /outside/repo/file.c\",\n" - " \"file\": \"/outside/repo/file.c\"\n" - " }\n" - "]"; +TEST(pipeline_file_delta_plan_falls_back_without_existing_ownership) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "main.go"}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "missing_existing_ownership"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - char **paths = NULL; - cbm_compile_flags_t **flags = NULL; - int n = cbm_parse_compile_commands(json, "/home/user/project", &paths, &flags); - ASSERT(n >= 2); /* At least main.c and server.cpp, outside file excluded */ +TEST(pipeline_file_delta_plan_falls_back_on_external_inbound_edge) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + int64_t main_id = + pipeline_delta_seed_existing_ownership_id(s, "test", "main.go", "test.main.Old"); + int64_t helper_id = + pipeline_delta_seed_existing_ownership_id(s, "test", "helper.go", "test.helper.Helper"); + ASSERT_GT(main_id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(helper_id, CBM_STORE_NO_NODE_ID); + cbm_edge_t inbound = {.project = "test", + .source_id = helper_id, + .target_id = main_id, + .type = "CALLS", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_insert_edge(s, &inbound), CBM_STORE_NO_NODE_ID); + + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "main.go"}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "inbound_edges_require_full"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - /* Find main.c */ - int main_idx = -1, server_idx = -1; - for (int i = 0; i < n; i++) { - if (strcmp(paths[i], "src/main.c") == 0) - main_idx = i; - if (strcmp(paths[i], "src/server.cpp") == 0) - server_idx = i; - } +TEST(pipeline_file_delta_plan_falls_back_on_unowned_structural_inbound_edge) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + int64_t main_id = + pipeline_delta_seed_existing_ownership_id(s, "test", "main.go", "test.main.Old"); + ASSERT_GT(main_id, CBM_STORE_NO_NODE_ID); + + cbm_node_t folder = {.project = "test", + .label = "Folder", + .name = "test", + .qualified_name = "test", + .file_path = "", + .properties_json = "{}"}; + int64_t folder_id = cbm_store_upsert_node(s, &folder); + ASSERT_GT(folder_id, CBM_STORE_NO_NODE_ID); + + cbm_edge_t inbound = {.project = "test", + .source_id = folder_id, + .target_id = main_id, + .type = "CONTAINS_FILE", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_insert_edge(s, &inbound), CBM_STORE_NO_NODE_ID); + + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "main.go"}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "inbound_edges_require_full"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - ASSERT(main_idx >= 0); - ASSERT_EQ(flags[main_idx]->include_count, 2); - ASSERT_EQ(flags[main_idx]->define_count, 2); - ASSERT_STR_EQ(flags[main_idx]->standard, "c11"); +TEST(pipeline_file_delta_plan_accepts_full_pipeline_structure_edge) { + enum { PIPELINE_DELTA_TEST_BASE_GENERATION = 1 }; + const char *project = "test"; + const char *rel_path = "src/main.go"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_project_node(s, project), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, rel_path, "test.src.main.Old"), + CBM_STORE_OK); - ASSERT(server_idx >= 0); - ASSERT_EQ(flags[server_idx]->include_count, 2); - ASSERT_EQ(flags[server_idx]->define_count, 1); - ASSERT_STR_EQ(flags[server_idx]->standard, "c++17"); + char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__"); + char *folder_qn = cbm_pipeline_fqn_folder(project, "src"); + ASSERT_NOT_NULL(file_qn); + ASSERT_NOT_NULL(folder_qn); + + cbm_node_t file_node = {.project = (char *)project, + .label = "File", + .name = "main.go", + .qualified_name = file_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}; + int64_t file_id = cbm_store_upsert_node(s, &file_node); + ASSERT_GT(file_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_node_owner(s, project, file_id, rel_path, + PIPELINE_DELTA_TEST_BASE_GENERATION), + CBM_STORE_OK); - /* Verify outside-repo file excluded */ - for (int i = 0; i < n; i++) { - ASSERT(strstr(paths[i], "outside") == NULL); - } + cbm_node_t folder_node = {.project = (char *)project, + .label = "Folder", + .name = "src", + .qualified_name = folder_qn, + .file_path = "src", + .properties_json = "{}"}; + int64_t folder_id = cbm_store_upsert_node(s, &folder_node); + ASSERT_GT(folder_id, CBM_STORE_NO_NODE_ID); + cbm_edge_t contains = {.project = (char *)project, + .source_id = folder_id, + .target_id = file_id, + .type = "CONTAINS_FILE", + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &contains); + ASSERT_GT(edge_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL, + PIPELINE_DELTA_TEST_BASE_GENERATION), + CBM_STORE_OK); - /* Cleanup */ - for (int i = 0; i < n; i++) { - free(paths[i]); - cbm_compile_flags_free(flags[i]); - } - free(paths); - free(flags); - PASS(); -} + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(scratch); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Project", project, project, NULL, 0, 0, "{}"), 0); + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, project, rel_path, NULL), 0); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Function", "New", "test.src.main.New", rel_path, 1, + 1, "{\"is_exported\":true}"), + 0); -TEST(compile_commands_parse_empty) { - char **paths = NULL; - cbm_compile_flags_t **flags = NULL; - int n = cbm_parse_compile_commands("[]", "/repo", &paths, &flags); - ASSERT_EQ(n, 0); - free(paths); - free(flags); + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, rel_path, 1, &delta), + CBM_STORE_OK); + const cbm_store_delta_edge_t *structure_edge = + pipeline_delta_find_edge(&delta, "CONTAINS_FILE"); + ASSERT_NOT_NULL(structure_edge); + ASSERT_STR_EQ(structure_edge->source_qn, folder_qn); + ASSERT_STR_EQ(structure_edge->target_qn, file_qn); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(cbm_store_publish_file_delta(s, &delta.delta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "CONTAINS_FILE"), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(folder_qn); + free(file_qn); + cbm_store_close(s); PASS(); } -TEST(compile_commands_parse_invalid) { - char **paths = NULL; - cbm_compile_flags_t **flags = NULL; - int n = cbm_parse_compile_commands("not json", "/repo", &paths, &flags); - ASSERT(n < 0); +TEST(pipeline_file_delta_plan_falls_back_on_new_folder_structure_edge) { + const char *project = "test"; + const char *rel_path = "src/main.go"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, rel_path, "test.src.main.Old"), + CBM_STORE_OK); + + char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__"); + char *folder_qn = cbm_pipeline_fqn_folder(project, "src"); + ASSERT_NOT_NULL(file_qn); + ASSERT_NOT_NULL(folder_qn); + + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(scratch); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Project", project, project, NULL, 0, 0, "{}"), 0); + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, project, rel_path, NULL), 0); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Function", "New", "test.src.main.New", rel_path, 1, + 1, "{\"is_exported\":true}"), + 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, rel_path, 1, &delta), + CBM_STORE_OK); + const cbm_store_delta_edge_t *structure_edge = + pipeline_delta_find_edge(&delta, "CONTAINS_FILE"); + ASSERT_NOT_NULL(structure_edge); + ASSERT_STR_EQ(structure_edge->source_qn, folder_qn); + ASSERT_STR_EQ(structure_edge->target_qn, file_qn); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "unresolved_edge_endpoint"); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(folder_qn); + free(file_qn); + cbm_store_close(s); PASS(); } -/* ── Suite ─────────────────────────────────────────────────────── */ +TEST(pipeline_file_delta_apply_inserts_and_prunes_new_folder_context) { + enum { + PIPELINE_NEW_FOLDER_DELTA_COUNT = 1, + }; + const char *project = "test"; + const char *rel_path = "src/main.go"; + const char *new_qn = "test.src.main.New"; -/* ── Infrascan: file identification ──────────────────────────────── */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_project_node(s, project), CBM_STORE_OK); + + char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__"); + char *folder_qn = cbm_pipeline_fqn_folder(project, "src"); + ASSERT_NOT_NULL(file_qn); + ASSERT_NOT_NULL(folder_qn); + + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(scratch); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Project", project, project, NULL, 0, 0, "{}"), 0); + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, project, rel_path, NULL), 0); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Function", "New", new_qn, rel_path, 1, 1, + "{\"is_exported\":true}"), + 0); + + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, rel_path, 0, &delta), + CBM_STORE_OK); + ASSERT_EQ(delta.delta.context_node_count, 1); + ASSERT_EQ(delta.delta.context_edge_count, 1); + ASSERT_STR_EQ(delta.context_nodes[0].qualified_name, folder_qn); + ASSERT_STR_EQ(delta.context_edges[0].type, "CONTAINS_FOLDER"); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + delta.delta.generation = 0; + delta.file_state.generation = 0; + + cbm_pipeline_file_delta_plan_t preflight_plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &preflight_plan), + CBM_STORE_OK); + ASSERT_EQ(preflight_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + cbm_pipeline_file_delta_plan_free(&preflight_plan); -TEST(infra_is_compose_file) { - /* Port of TestIsComposeFile (8 cases) */ - ASSERT(cbm_is_compose_file("docker-compose.yml")); - ASSERT(cbm_is_compose_file("docker-compose.yaml")); - ASSERT(cbm_is_compose_file("docker-compose.prod.yml")); - ASSERT(cbm_is_compose_file("compose.yml")); - ASSERT(cbm_is_compose_file("compose.yaml")); - ASSERT(!cbm_is_compose_file("mycompose.yml")); - ASSERT(!cbm_is_compose_file("docker-compose.txt")); - ASSERT(!cbm_is_compose_file("Dockerfile")); - PASS(); -} + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, 0); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, generation), CBM_STORE_OK); -TEST(infra_is_cloudbuild_file) { - /* Port of TestIsCloudbuildFile (5 cases) */ - ASSERT(cbm_is_cloudbuild_file("cloudbuild.yaml")); - ASSERT(cbm_is_cloudbuild_file("cloudbuild.yml")); - ASSERT(cbm_is_cloudbuild_file("cloudbuild-prod.yaml")); - ASSERT(cbm_is_cloudbuild_file("Cloudbuild.yml")); - ASSERT(!cbm_is_cloudbuild_file("build.yaml")); + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t apply_plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_NEW_FOLDER_DELTA_COUNT, + CBM_SZ_4, &apply_plan), + CBM_STORE_OK); + ASSERT_EQ(apply_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_qn), 1); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, folder_qn), 1); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "CONTAINS_FOLDER"), 1); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "CONTAINS_FILE"), 1); + + int node_owners = 0; + int edge_owners = 0; + ASSERT_EQ(cbm_store_count_file_delta_owners(s, project, "src", &node_owners, &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 0); + ASSERT_EQ(edge_owners, 0); + ASSERT_EQ(cbm_store_count_file_delta_owners(s, project, rel_path, &node_owners, + &edge_owners), + CBM_STORE_OK); + ASSERT_GT(node_owners, 0); + ASSERT_GT(edge_owners, 0); + + cbm_pipeline_file_delta_plan_free(&apply_plan); + cbm_pipeline_file_delta_free(&delta); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + cbm_pipeline_file_delta_t delete_delta = { + .delta = {.project = project, .rel_path = rel_path, .generation = generation}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; + const cbm_pipeline_file_delta_t *delete_deltas[] = {&delete_delta}; + cbm_pipeline_file_delta_plan_t delete_plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, delete_deltas, + PIPELINE_NEW_FOLDER_DELTA_COUNT, CBM_SZ_4, + &delete_plan), + CBM_STORE_OK); + ASSERT_EQ(delete_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, file_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, folder_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, project), 1); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "CONTAINS_FOLDER"), 0); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "CONTAINS_FILE"), 0); + + cbm_pipeline_file_delta_plan_free(&delete_plan); + cbm_gbuf_free(scratch); + free(folder_qn); + free(file_qn); + cbm_store_close(s); PASS(); } -TEST(infra_is_shell_script) { - /* Port of TestIsShellScript (5 cases) */ - ASSERT(cbm_is_shell_script("run.sh", ".sh")); - ASSERT(cbm_is_shell_script("deploy.bash", ".bash")); - ASSERT(cbm_is_shell_script("init.zsh", ".zsh")); - ASSERT(!cbm_is_shell_script("main.py", ".py")); - ASSERT(!cbm_is_shell_script("Dockerfile", "")); +TEST(pipeline_file_delta_plan_accepts_regenerated_structural_inbound_edge) { + enum { PIPELINE_DELTA_TEST_BASE_GENERATION = 1 }; + const char *project = "test"; + const char *rel_path = "src/main.go"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + + char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__"); + char *folder_qn = cbm_pipeline_fqn_folder(project, "src"); + ASSERT_NOT_NULL(file_qn); + ASSERT_NOT_NULL(folder_qn); + + cbm_node_t old_file = {.project = (char *)project, + .label = "File", + .name = "main.go", + .qualified_name = file_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}; + int64_t file_id = cbm_store_upsert_node(s, &old_file); + ASSERT_GT(file_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_node_owner(s, project, file_id, rel_path, + PIPELINE_DELTA_TEST_BASE_GENERATION), + CBM_STORE_OK); + cbm_file_state_t base_state = {.project = (char *)project, + .rel_path = (char *)rel_path, + .content_hash = "base-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "go", + .pass_fingerprint = "test-pass", + .generation = PIPELINE_DELTA_TEST_BASE_GENERATION, + .indexed_at = "2026-06-30T00:00:00Z"}; + ASSERT_EQ(cbm_store_upsert_file_state(s, &base_state), CBM_STORE_OK); + + cbm_node_t folder = {.project = (char *)project, + .label = "Folder", + .name = "src", + .qualified_name = folder_qn, + .file_path = "src", + .properties_json = "{}"}; + int64_t folder_id = cbm_store_upsert_node(s, &folder); + ASSERT_GT(folder_id, CBM_STORE_NO_NODE_ID); + cbm_edge_t contains = {.project = (char *)project, + .source_id = folder_id, + .target_id = file_id, + .type = "CONTAINS_FILE", + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &contains); + ASSERT_GT(edge_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL, + PIPELINE_DELTA_TEST_BASE_GENERATION), + CBM_STORE_OK); + + cbm_node_t new_file = {.project = (char *)project, + .label = "File", + .name = "main.go", + .qualified_name = file_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}; + cbm_store_delta_edge_t regenerated_edge = {.source_qn = folder_qn, + .target_qn = file_qn, + .type = "CONTAINS_FILE", + .properties_json = "{}"}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = rel_path, + .nodes = &new_file, + .node_count = 1, + .edges = ®enerated_edge, + .edge_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, rel_path), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + free(folder_qn); + free(file_qn); + cbm_store_close(s); PASS(); } -TEST(infra_is_dockerfile) { - ASSERT(cbm_is_dockerfile("Dockerfile")); - ASSERT(cbm_is_dockerfile("dockerfile")); - ASSERT(cbm_is_dockerfile("Dockerfile.prod")); - ASSERT(cbm_is_dockerfile("app.dockerfile")); - ASSERT(!cbm_is_dockerfile("docker-compose.yml")); - ASSERT(!cbm_is_dockerfile("main.go")); +TEST(pipeline_file_delta_plan_accepts_regenerated_file_owned_unowned_source_edge) { + const char *project = "test"; + const char *rel_path = "src/main.go"; + const char *source_qn = "test.src.module"; + const char *target_qn = "test.src.main.Run"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_file_owned_unowned_source_edge( + s, project, rel_path, source_qn, target_qn, "CALLS"), + CBM_STORE_OK); + + cbm_node_t replacement_node = {.project = (char *)project, + .label = "Function", + .name = "Run", + .qualified_name = (char *)target_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}; + cbm_store_delta_edge_t replacement_edge = {.source_qn = source_qn, + .target_qn = target_qn, + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = rel_path, + .nodes = &replacement_node, + .node_count = 1, + .edges = &replacement_edge, + .edge_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, rel_path), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_is_kustomize_file) { - ASSERT(cbm_is_kustomize_file("kustomization.yaml")); - ASSERT(cbm_is_kustomize_file("kustomization.yml")); - ASSERT(cbm_is_kustomize_file("KUSTOMIZATION.YAML")); /* case-insensitive */ - ASSERT(!cbm_is_kustomize_file("deployment.yaml")); - ASSERT(!cbm_is_kustomize_file("kustomize.yaml")); - ASSERT(!cbm_is_kustomize_file(NULL)); +TEST(pipeline_file_delta_plan_falls_back_on_stale_file_owned_unowned_source_edge) { + const char *project = "test"; + const char *rel_path = "src/main.go"; + const char *source_qn = "test.src.module"; + const char *target_qn = "test.src.main.Run"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_file_owned_unowned_source_edge( + s, project, rel_path, source_qn, target_qn, "CALLS"), + CBM_STORE_OK); + + cbm_node_t replacement_node = {.project = (char *)project, + .label = "Function", + .name = "Run", + .qualified_name = (char *)target_qn, + .file_path = (char *)rel_path, + .properties_json = "{}"}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = rel_path, + .nodes = &replacement_node, + .node_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "inbound_edges_require_full"); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_is_k8s_manifest) { - const char *deploy = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n"; - const char *plain = "name: foo\nvalue: bar\n"; - const char *kust = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n"; +TEST(pipeline_file_delta_plan_falls_back_without_file_metadata) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "main.go"}}; - ASSERT(cbm_is_k8s_manifest("deployment.yaml", deploy)); - ASSERT(!cbm_is_k8s_manifest("deployment.yaml", plain)); - /* kustomize file should return false even if it has apiVersion */ - ASSERT(!cbm_is_k8s_manifest("kustomization.yaml", kust)); - ASSERT(!cbm_is_k8s_manifest(NULL, deploy)); - ASSERT(!cbm_is_k8s_manifest("deployment.yaml", NULL)); + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "missing_file_metadata"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_is_env_file) { - ASSERT(cbm_is_env_file(".env")); - ASSERT(cbm_is_env_file(".env.local")); - ASSERT(cbm_is_env_file("prod.env")); - ASSERT(!cbm_is_env_file("main.go")); - ASSERT(!cbm_is_env_file("env.txt")); +TEST(pipeline_file_delta_plan_falls_back_on_unsupported_edges) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", .rel_path = "main.go"}, .unsupported_edge_count = 1}; + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "unsupported_edges"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -/* ── Infrascan: cleanJSONBrackets ───────────────────────────────── */ +TEST(pipeline_file_delta_plan_falls_back_on_delete) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "gone.go"}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; -TEST(infra_clean_json_brackets) { - /* Port of TestCleanJSONBrackets (4 cases) */ - char out[256]; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "missing_generation"); + ASSERT_EQ(plan.affected_count, 0); - cbm_clean_json_brackets("[\"./server\"]", out, sizeof(out)); - ASSERT_STR_EQ(out, "./server"); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - cbm_clean_json_brackets("[\"python\", \"main.py\"]", out, sizeof(out)); - ASSERT_STR_EQ(out, "python main.py"); +TEST(pipeline_file_delta_apply_deletes_owned_file_delta) { + enum { + PIPELINE_DELETE_BASE_GENERATION = 1, + PIPELINE_DELETE_FINAL_GENERATION = 2, + PIPELINE_DELETE_DELTA_COUNT = 1, + }; + const char *project = "test"; + const char *rel_path = "gone.go"; + const char *old_qn = "test.gone.Old"; - cbm_clean_json_brackets("./server", out, sizeof(out)); - ASSERT_STR_EQ(out, "./server"); + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); - cbm_clean_json_brackets("[\"./app\", \"--flag\", \"value\"]", out, sizeof(out)); - ASSERT_STR_EQ(out, "./app --flag value"); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_DELETE_BASE_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, rel_path, old_qn), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_DELETE_FINAL_GENERATION); + + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = rel_path, + .generation = generation}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_DELETE_DELTA_COUNT, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_qn), 0); + cbm_file_state_t state = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, project, rel_path, &state), CBM_STORE_NOT_FOUND); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -/* ── Infrascan: secret detection ────────────────────────────────── */ +TEST(pipeline_file_delta_apply_mixed_delete_upsert_batch) { + enum { + PIPELINE_RENAME_BASE_GENERATION = 1, + PIPELINE_RENAME_FINAL_GENERATION = 2, + PIPELINE_RENAME_DELTA_COUNT = 2, + }; + const char *project = "test"; + const char *old_rel = "pkg/file_0000.go"; + const char *new_rel = "pkg/file_renamed.go"; + const char *old_qn = "test.pkg.file_0000.OldName"; + const char *new_qn = "test.pkg.file_renamed.NewName"; -TEST(infra_secret_detection) { - /* Key-based detection */ - ASSERT(cbm_is_secret_binding("JWT_SECRET", "anything")); - ASSERT(cbm_is_secret_binding("API_KEY", "anything")); - ASSERT(cbm_is_secret_binding("my_password", "anything")); - ASSERT(cbm_is_secret_binding("AUTH_TOKEN", "anything")); - ASSERT(!cbm_is_secret_binding("DATABASE_URL", "https://db.example.com")); - - /* Value-based detection */ - ASSERT(cbm_is_secret_value("sk-1234567890abcdef12345")); - ASSERT(cbm_is_secret_value("-----BEGIN RSA PRIVATE KEY-----")); - ASSERT(!cbm_is_secret_value("https://db.example.com")); - ASSERT(!cbm_is_secret_value("hello world")); - ASSERT(!cbm_is_secret_value("8080")); - - /* isSecretBinding checks both */ - ASSERT(cbm_is_secret_binding("ANYTHING", "sk-1234567890abcdef12345")); - ASSERT(!cbm_is_secret_binding("PORT", "8080")); + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_project_node(s, project), CBM_STORE_OK); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_RENAME_BASE_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, old_rel, old_qn), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation_memory(s, project, old_rel, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_RENAME_BASE_GENERATION); + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_RENAME_FINAL_GENERATION); + + cbm_gbuf_t *scratch = cbm_gbuf_new(project, "/tmp/test"); + ASSERT_NOT_NULL(scratch); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Project", project, project, NULL, 0, 0, "{}"), 0); + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, project, new_rel, NULL), 0); + ASSERT_GT(cbm_gbuf_upsert_node(scratch, "Function", "NewName", new_qn, new_rel, 1, 1, + "{\"is_exported\":true}"), + 0); + + cbm_pipeline_file_delta_t upsert_delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(scratch, project, new_rel, + PIPELINE_RENAME_FINAL_GENERATION, + &upsert_delta), + CBM_STORE_OK); + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&upsert_delta, &hash, &state); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&upsert_delta, + PIPELINE_RENAME_FINAL_GENERATION), + CBM_STORE_OK); + cbm_pipeline_file_delta_t delete_delta = { + .delta = {.project = project, + .rel_path = old_rel, + .generation = PIPELINE_RENAME_FINAL_GENERATION}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; + const cbm_pipeline_file_delta_t *deltas[] = {&delete_delta, &upsert_delta}; + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_RENAME_DELTA_COUNT, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_qn), 1); + ASSERT_EQ(pipeline_store_file_state_generation_memory(s, project, old_rel, &generation), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(pipeline_store_file_state_generation_memory(s, project, new_rel, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_RENAME_FINAL_GENERATION); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_pipeline_file_delta_free(&upsert_delta); + cbm_gbuf_free(scratch); + cbm_store_close(s); PASS(); } -/* ── Infrascan: Dockerfile parser ───────────────────────────────── */ +TEST(pipeline_file_delta_apply_falls_back_on_delete_batch) { + enum { + PIPELINE_DELETE_BATCH_BASE_GENERATION = 1, + PIPELINE_DELETE_BATCH_FINAL_GENERATION = 2, + PIPELINE_DELETE_BATCH_COUNT = 2, + }; + const char *project = "test"; + const char *first_rel = "one.go"; + const char *second_rel = "two.go"; + const char *first_qn = "test.one.Old"; + const char *second_qn = "test.two.Old"; -/* Helper: find env var by key in result */ -static const char *find_env_var(const cbm_env_kv_t *vars, int count, const char *key) { - for (int i = 0; i < count; i++) { - if (strcmp(vars[i].key, key) == 0) - return vars[i].value; - } - return NULL; -} + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); -/* Helper: check if string array contains value */ -static bool str_array_contains(const char (*arr)[32], int count, const char *val) { - for (int i = 0; i < count; i++) { - if (strcmp(arr[i], val) == 0) - return true; - } - return false; -} + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_DELETE_BATCH_BASE_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, first_rel, first_qn), + CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, second_rel, second_qn), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, PIPELINE_DELETE_BATCH_FINAL_GENERATION); + + cbm_pipeline_file_delta_t first = {.delta = {.project = project, + .rel_path = first_rel, + .generation = generation}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; + cbm_pipeline_file_delta_t second = {.delta = {.project = project, + .rel_path = second_rel, + .generation = generation}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; + const cbm_pipeline_file_delta_t *deltas[] = {&first, &second}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_DELETE_BATCH_COUNT, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "delete_batch_requires_full"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, first_qn), 1); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, second_qn), 1); -static bool str_array_128_contains(const char (*arr)[128], int count, const char *val) { - for (int i = 0; i < count; i++) { - if (strcmp(arr[i], val) == 0) - return true; - } - return false; + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); } -static bool str_array_256_contains(const char (*arr)[256], int count, const char *val) { - for (int i = 0; i < count; i++) { - if (strcmp(arr[i], val) == 0) - return true; - } - return false; -} +TEST(pipeline_file_delta_apply_falls_back_when_frontier_path_missing_from_batch) { + enum { PIPELINE_FRONTIER_MISSING_DELTA_COUNT = 1 }; + const char *project = "test"; + const char *lib_rel = "lib.go"; + const char *main_rel = "main.go"; + const char *lib_qn = "test.lib.Hot"; + const char *new_lib_qn = "test.lib.HotRenamed"; -TEST(infra_parse_dockerfile_multistage) { - /* Port of TestParseDockerfile "multi-stage with all directives" */ - const char *src = "FROM golang:1.23-alpine AS builder\n" - "WORKDIR /app\n" - "ARG SSH_PRIVATE_KEY\n" - "RUN go build -o server .\n" - "\n" - "FROM alpine:3.19\n" - "WORKDIR /usr/app\n" - "ENV PORT=8080\n" - "ENV PYTHONUNBUFFERED=1\n" - "EXPOSE 8080 443\n" - "USER appuser\n" - "CMD [\"./server\"]\n" - "HEALTHCHECK CMD wget http://localhost:8080/health\n"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, lib_rel, lib_qn), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, project, lib_qn, lib_rel, CBM_STORE_NO_NODE_ID, + 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, main_rel, "test.lib", "Hot", lib_qn, 1), + CBM_STORE_OK); - cbm_dockerfile_result_t r; - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); - ASSERT_STR_EQ(r.base_image, "alpine:3.19"); - ASSERT_EQ(r.stage_count, 2); - ASSERT_STR_EQ(r.stage_images[0], "golang:1.23-alpine"); - ASSERT_STR_EQ(r.stage_images[1], "alpine:3.19"); - ASSERT(str_array_contains(r.exposed_ports, r.port_count, "8080")); - ASSERT(str_array_contains(r.exposed_ports, r.port_count, "443")); - ASSERT_STR_EQ(r.workdir, "/usr/app"); - ASSERT_STR_EQ(r.user, "appuser"); - ASSERT_STR_EQ(r.cmd, "./server"); - ASSERT_STR_EQ(r.healthcheck, "wget http://localhost:8080/health"); + cbm_node_t nodes[1] = {{.project = (char *)project, + .label = "Function", + .name = "HotRenamed", + .qualified_name = (char *)new_lib_qn, + .file_path = (char *)lib_rel, + .properties_json = "{}"}}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = new_lib_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = {.delta = {.project = project, + .rel_path = lib_rel, + .nodes = nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, + PIPELINE_FRONTIER_MISSING_DELTA_COUNT, + CBM_SZ_4, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "frontier_requires_batch"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, lib_qn), 1); - ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "PORT")); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PORT"), "8080"); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PYTHONUNBUFFERED"), "1"); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - ASSERT(str_array_128_contains(r.build_args, r.build_arg_count, "SSH_PRIVATE_KEY")); +TEST(pipeline_file_delta_plan_falls_back_on_rename) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "new.go"}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_RENAME, + .old_rel_path = "old.go"}; + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "rename_requires_full"); + ASSERT_EQ(plan.affected_count, 0); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_parse_dockerfile_entrypoint) { - /* Port of TestParseDockerfile "single stage with entrypoint" */ - const char *src = "FROM python:3.9-slim\n" - "ENTRYPOINT [\"python\", \"main.py\"]\n"; - - cbm_dockerfile_result_t r; - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); - ASSERT_STR_EQ(r.base_image, "python:3.9-slim"); - ASSERT_STR_EQ(r.entrypoint, "python main.py"); - ASSERT_EQ(r.stage_count, 1); +TEST(pipeline_file_delta_plan_falls_back_on_unsupported_derived_view) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", + .rel_path = "main.go", + .derived_view_name = CBM_STORE_DERIVED_VIEW_PAGERANK, + .derived_status = CBM_STORE_DERIVED_STATUS_STALE}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "unsupported_derived_view"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_parse_dockerfile_secret_filtered) { - /* Port of TestParseDockerfile "secret env vars filtered" */ - const char *src = "FROM node:20\n" - "ENV API_KEY=sk-1234567890abcdef12345\n" - "ENV DATABASE_URL=https://db.example.com\n" - "ENV JWT_SECRET=supersecret\n"; +TEST(pipeline_file_delta_plan_falls_back_on_unresolved_edge_endpoint) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "main.go", "test.main.Old"), + CBM_STORE_OK); + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = "Run", + .qualified_name = "test.main.Run", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t edges[1] = {{.source_qn = "test.main.Run", + .target_qn = "test.missing.Helper", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", + .rel_path = "main.go", + .nodes = nodes, + .node_count = 1, + .edges = edges, + .edge_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "unresolved_edge_endpoint"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - cbm_dockerfile_result_t r; - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); +TEST(pipeline_file_delta_plan_accepts_resolved_external_edge_endpoint) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "main.go", "test.main.Old"), + CBM_STORE_OK); + cbm_node_t helper = {.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.helper.Helper", + .file_path = "helper.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &helper), 0); + + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = "Run", + .qualified_name = "test.main.Run", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t edges[1] = {{.source_qn = "test.main.Run", + .target_qn = "test.helper.Helper", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", + .rel_path = "main.go", + .nodes = nodes, + .node_count = 1, + .edges = edges, + .edge_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(plan.affected_count, 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, "main.go"), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} - /* API_KEY and JWT_SECRET should be filtered */ - ASSERT(find_env_var(r.env_vars, r.env_count, "API_KEY") == NULL); - ASSERT(find_env_var(r.env_vars, r.env_count, "JWT_SECRET") == NULL); +TEST(pipeline_file_delta_plan_falls_back_on_large_frontier) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "lib.go", "test.lib.Old"), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Hot", "lib.go", + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "a.go", "test.lib", "Hot", + "test.lib.Hot", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "b.go", "test.lib", "Hot", + "test.lib.Hot", 1), + CBM_STORE_OK); - /* DATABASE_URL should remain */ - ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "DATABASE_URL")); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"), "https://db.example.com"); + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.lib.HotRenamed", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t delta = { + .delta = {.project = "test", .rel_path = "lib.go", .exports = exports, .export_count = 1}}; + cbm_file_hash_t hash = {0}; + cbm_file_state_t state = {0}; + pipeline_delta_attach_test_metadata(&delta, &hash, &state); + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_2, &plan), CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "frontier_too_large"); + ASSERT_EQ(plan.affected_count, 3); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, "lib.go"), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, "a.go"), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, "b.go"), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_parse_dockerfile_expose_protocol) { - /* Port of TestParseDockerfile "expose with protocol suffix" */ - const char *src = "FROM nginx:latest\n" - "EXPOSE 80/tcp 443/tcp\n"; +TEST(pipeline_file_delta_plan_frontier_noop_mask_bounds_recursive_frontier) { + enum { + PIPELINE_NOOP_FRONTIER_DELTA_COUNT = 2, + PIPELINE_NOOP_FRONTIER_MAX_AFFECTED = 2, + }; + const char *project = "test"; + const char *lib_rel = "lib.py"; + const char *importer_rel = "a.py"; + const char *downstream_rel = "b.py"; + const char *lib_qn = "test.lib.Hot"; + const char *importer_qn = "test.a.Stable"; + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, lib_rel, lib_qn), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, importer_rel, importer_qn), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, project, lib_qn, lib_rel, + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, project, importer_qn, importer_rel, + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, importer_rel, "test.lib", "Hot", lib_qn, + 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, downstream_rel, "test.a", "Stable", + importer_qn, 1), + CBM_STORE_OK); - cbm_dockerfile_result_t r; - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); - ASSERT(str_array_contains(r.exposed_ports, r.port_count, "80")); - ASSERT(str_array_contains(r.exposed_ports, r.port_count, "443")); + cbm_store_symbol_export_t lib_exports[1] = { + {.qualified_name = "test.lib.HotRenamed", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_symbol_export_t importer_exports[1] = { + {.qualified_name = "test.a.StableRenamed", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t lib_delta = { + .delta = {.project = project, + .rel_path = lib_rel, + .exports = lib_exports, + .export_count = 1}}; + cbm_pipeline_file_delta_t importer_delta = { + .delta = {.project = project, + .rel_path = importer_rel, + .exports = importer_exports, + .export_count = 1}}; + cbm_file_hash_t lib_hash = {0}; + cbm_file_hash_t importer_hash = {0}; + cbm_file_state_t lib_state = {0}; + cbm_file_state_t importer_state = {0}; + pipeline_delta_attach_test_metadata(&lib_delta, &lib_hash, &lib_state); + pipeline_delta_attach_test_metadata(&importer_delta, &importer_hash, &importer_state); + const cbm_pipeline_file_delta_t *deltas[PIPELINE_NOOP_FRONTIER_DELTA_COUNT] = { + &lib_delta, &importer_delta}; + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta_batch( + s, deltas, PIPELINE_NOOP_FRONTIER_DELTA_COUNT, + PIPELINE_NOOP_FRONTIER_MAX_AFFECTED, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(plan.reason, "frontier_too_large"); + ASSERT_EQ(plan.affected_count, 3); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, lib_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, importer_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, downstream_rel), 1); + cbm_pipeline_file_delta_plan_free(&plan); + + bool frontier_noop_mask[PIPELINE_NOOP_FRONTIER_DELTA_COUNT] = {false, true}; + ASSERT_EQ(cbm_pipeline_plan_file_delta_batch_with_frontier_noop_mask( + s, deltas, frontier_noop_mask, PIPELINE_NOOP_FRONTIER_DELTA_COUNT, + PIPELINE_NOOP_FRONTIER_MAX_AFFECTED, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(plan.affected_count, PIPELINE_NOOP_FRONTIER_MAX_AFFECTED); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, lib_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, importer_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, downstream_rel), 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_parse_dockerfile_env_space) { - /* Port of TestParseDockerfile "ENV space-separated format" */ - const char *src = "FROM python:3.9\n" - "ENV PYTHONPATH /usr/app\n"; +TEST(pipeline_file_delta_plan_frontier_noop_mask_skips_masked_inbound_precheck) { + enum { + PIPELINE_NOOP_INBOUND_DELTA_COUNT = 2, + PIPELINE_NOOP_INBOUND_MAX_AFFECTED = 2, + PIPELINE_NOOP_INBOUND_GENERATION = 1, + }; + const char *project = "test"; + const char *lib_rel = "lib.py"; + const char *importer_rel = "a.py"; + const char *downstream_rel = "b.py"; + const char *lib_qn = "test.lib.Hot"; + const char *importer_qn = "test.a.Stable"; + const char *downstream_qn = "test.b.UsesStable"; + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, project, lib_rel, lib_qn), CBM_STORE_OK); + int64_t importer_id = + pipeline_delta_seed_existing_ownership_id(s, project, importer_rel, importer_qn); + int64_t downstream_id = + pipeline_delta_seed_existing_ownership_id(s, project, downstream_rel, downstream_qn); + ASSERT_GT(importer_id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(downstream_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, project, lib_qn, lib_rel, + CBM_STORE_NO_NODE_ID, + PIPELINE_NOOP_INBOUND_GENERATION), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, importer_rel, "test.lib", "Hot", lib_qn, + PIPELINE_NOOP_INBOUND_GENERATION), + CBM_STORE_OK); - cbm_dockerfile_result_t r; - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); - ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "PYTHONPATH")); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PYTHONPATH"), "/usr/app"); + cbm_edge_t downstream_call = {.project = (char *)project, + .source_id = downstream_id, + .target_id = importer_id, + .type = "CALLS", + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &downstream_call); + ASSERT_GT(edge_id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(cbm_store_upsert_edge_owner(s, project, edge_id, downstream_rel, NULL, + PIPELINE_NOOP_INBOUND_GENERATION), + CBM_STORE_OK); + + cbm_store_symbol_export_t lib_exports[1] = { + {.qualified_name = lib_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_symbol_export_t importer_exports[1] = { + {.qualified_name = importer_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t lib_delta = { + .delta = {.project = project, + .rel_path = lib_rel, + .exports = lib_exports, + .export_count = 1}}; + cbm_pipeline_file_delta_t importer_delta = { + .delta = {.project = project, + .rel_path = importer_rel, + .exports = importer_exports, + .export_count = 1}}; + cbm_file_hash_t lib_hash = {0}; + cbm_file_hash_t importer_hash = {0}; + cbm_file_state_t lib_state = {0}; + cbm_file_state_t importer_state = {0}; + pipeline_delta_attach_test_metadata(&lib_delta, &lib_hash, &lib_state); + pipeline_delta_attach_test_metadata(&importer_delta, &importer_hash, &importer_state); + const cbm_pipeline_file_delta_t *deltas[PIPELINE_NOOP_INBOUND_DELTA_COUNT] = { + &lib_delta, &importer_delta}; + bool frontier_noop_mask[PIPELINE_NOOP_INBOUND_DELTA_COUNT] = {false, true}; + + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta_batch_with_frontier_noop_mask( + s, deltas, frontier_noop_mask, PIPELINE_NOOP_INBOUND_DELTA_COUNT, + PIPELINE_NOOP_INBOUND_MAX_AFFECTED, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(plan.affected_count, 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, lib_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, importer_rel), 0); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, downstream_rel), 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -TEST(infra_parse_dockerfile_empty) { - /* Port of TestParseDockerfileEmpty */ - cbm_dockerfile_result_t r; - ASSERT_EQ(cbm_parse_dockerfile_source("# just a comment\n", &r), -1); +TEST(pipeline_file_delta_plan_batch_accepts_mutual_frontier) { + enum { + PIPELINE_MUTUAL_GENERATION = 1, + PIPELINE_MUTUAL_SINGLE_COUNT = 1, + PIPELINE_MUTUAL_DELTA_COUNT = 2, + PIPELINE_MUTUAL_MAX_AFFECTED = 4, + }; + const char *project = "test"; + const char *a_rel = "a.go"; + const char *b_rel = "b.go"; + const char *old_a_qn = "test.a.OldA"; + const char *old_b_qn = "test.b.OldB"; + const char *new_a_qn = "test.a.NewA"; + const char *new_b_qn = "test.b.NewB"; + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + int64_t old_a_id = pipeline_delta_seed_existing_ownership_id(s, project, a_rel, old_a_qn); + int64_t old_b_id = pipeline_delta_seed_existing_ownership_id(s, project, b_rel, old_b_qn); + ASSERT_GT(old_a_id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(old_b_id, CBM_STORE_NO_NODE_ID); + cbm_edge_t old_a_to_b = {.project = (char *)project, + .source_id = old_a_id, + .target_id = old_b_id, + .type = "CALLS", + .properties_json = "{}"}; + cbm_edge_t old_b_to_a = {.project = (char *)project, + .source_id = old_b_id, + .target_id = old_a_id, + .type = "CALLS", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_insert_edge(s, &old_a_to_b), 0); + ASSERT_GT(cbm_store_insert_edge(s, &old_b_to_a), 0); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, project, old_a_qn, a_rel, old_a_id, + PIPELINE_MUTUAL_GENERATION), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, project, old_b_qn, b_rel, old_b_id, + PIPELINE_MUTUAL_GENERATION), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, a_rel, "test.b", "OldB", old_b_qn, + PIPELINE_MUTUAL_GENERATION), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, project, b_rel, "test.a", "OldA", old_a_qn, + PIPELINE_MUTUAL_GENERATION), + CBM_STORE_OK); + + cbm_node_t a_nodes[PIPELINE_MUTUAL_SINGLE_COUNT] = {{.project = (char *)project, + .label = "Function", + .name = "NewA", + .qualified_name = (char *)new_a_qn, + .file_path = (char *)a_rel, + .properties_json = "{}"}}; + cbm_node_t b_nodes[PIPELINE_MUTUAL_SINGLE_COUNT] = {{.project = (char *)project, + .label = "Function", + .name = "NewB", + .qualified_name = (char *)new_b_qn, + .file_path = (char *)b_rel, + .properties_json = "{}"}}; + cbm_store_delta_edge_t a_edges[PIPELINE_MUTUAL_SINGLE_COUNT] = { + {.source_qn = new_a_qn, + .target_qn = new_b_qn, + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}}; + cbm_store_delta_edge_t b_edges[PIPELINE_MUTUAL_SINGLE_COUNT] = { + {.source_qn = new_b_qn, + .target_qn = new_a_qn, + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}}; + cbm_store_symbol_export_t a_exports[PIPELINE_MUTUAL_SINGLE_COUNT] = { + {.qualified_name = new_a_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_symbol_export_t b_exports[PIPELINE_MUTUAL_SINGLE_COUNT] = { + {.qualified_name = new_b_qn, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_pipeline_file_delta_t a_delta = { + .delta = {.project = project, + .rel_path = a_rel, + .nodes = a_nodes, + .node_count = PIPELINE_MUTUAL_SINGLE_COUNT, + .edges = a_edges, + .edge_count = PIPELINE_MUTUAL_SINGLE_COUNT, + .exports = a_exports, + .export_count = PIPELINE_MUTUAL_SINGLE_COUNT}}; + cbm_pipeline_file_delta_t b_delta = { + .delta = {.project = project, + .rel_path = b_rel, + .nodes = b_nodes, + .node_count = PIPELINE_MUTUAL_SINGLE_COUNT, + .edges = b_edges, + .edge_count = PIPELINE_MUTUAL_SINGLE_COUNT, + .exports = b_exports, + .export_count = PIPELINE_MUTUAL_SINGLE_COUNT}}; + cbm_file_hash_t a_hash = {0}; + cbm_file_hash_t b_hash = {0}; + cbm_file_state_t a_state = {0}; + cbm_file_state_t b_state = {0}; + pipeline_delta_attach_test_metadata(&a_delta, &a_hash, &a_state); + pipeline_delta_attach_test_metadata(&b_delta, &b_hash, &b_state); + + const cbm_pipeline_file_delta_t *deltas[] = {&a_delta, &b_delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta_batch(s, deltas, PIPELINE_MUTUAL_DELTA_COUNT, + PIPELINE_MUTUAL_MAX_AFFECTED, &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_STR_EQ(plan.reason, "candidate"); + ASSERT_EQ(plan.affected_count, PIPELINE_MUTUAL_DELTA_COUNT); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, a_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, b_rel), 1); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); PASS(); } -/* ── Infrascan: Dotenv parser ───────────────────────────────────── */ +TEST(pipeline_file_delta_orchestrates_descriptor_plan_and_publish) { + enum { + BASE_GENERATION = 1, + FINAL_GENERATION = 2, + PIPELINE_DELTA_PARITY_MAX_AFFECTED = CBM_SZ_4, + PIPELINE_DELTA_PARITY_SINGLE_COUNT = 1, + PIPELINE_DELTA_PARITY_BATCH_COUNT = 2, + EXPECTED_FINAL_CALLS_EDGES = 1, + EXPECTED_FINAL_IMPORTS_EDGES = 1, + EXPECTED_FINAL_EDGES = EXPECTED_FINAL_CALLS_EDGES + EXPECTED_FINAL_IMPORTS_EDGES, + }; + const char *project = "test"; + const char *helper_rel = "helper.go"; + const char *main_rel = "main.go"; + const char *old_helper_qn = "test.helper.Helper"; + const char *new_helper_qn = "test.helper.NewHelper"; + const char *old_main_qn = "test.main.Old"; + const char *new_main_qn = "test.main.New"; + + char *tmp = th_mktempdir("cbm_delta_pipeline"); + ASSERT_NOT_NULL(tmp); + const char *helper_path = TH_PATH(tmp, helper_rel); + const char *main_path = TH_PATH(tmp, main_rel); + ASSERT_EQ(th_write_file(helper_path, "package helper\nfunc Helper() {}\n"), 0); + ASSERT_EQ(th_write_file(main_path, "package main\nfunc Old() {}\n"), 0); -TEST(infra_parse_dotenv) { - /* Port of TestParseDotenvFile */ - const char *src = "# Database config\n" - "DATABASE_HOST=localhost\n" - "DATABASE_PORT=5432\n" - "DATABASE_NAME=mydb\n" - "API_SECRET=should-not-appear\n" - "PLAIN_VALUE=hello world\n"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, tmp), CBM_STORE_OK); - cbm_dotenv_result_t r; - ASSERT_EQ(cbm_parse_dotenv_source(src, &r), 0); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + + cbm_gbuf_t *base_gb = cbm_gbuf_new(project, tmp); + ASSERT_NOT_NULL(base_gb); + int64_t base_helper_id = cbm_gbuf_upsert_node(base_gb, "Function", "Helper", + old_helper_qn, helper_rel, 1, 1, + "{\"is_exported\":true}"); + int64_t base_file_id = cbm_gbuf_upsert_node(base_gb, "File", main_rel, + "test.main.__file__", main_rel, 1, 1, "{}"); + int64_t base_main_id = cbm_gbuf_upsert_node(base_gb, "Function", "Old", old_main_qn, + main_rel, 1, 1, "{\"is_exported\":true}"); + ASSERT_GT(base_helper_id, 0); + ASSERT_GT(base_file_id, 0); + ASSERT_GT(base_main_id, 0); + const cbm_gbuf_node_t *base_helper = cbm_gbuf_find_by_qn(base_gb, old_helper_qn); + ASSERT_NOT_NULL(base_helper); + cbm_pipeline_ctx_t base_ctx = {.project_name = project, .repo_path = tmp, .gbuf = base_gb}; + ASSERT_EQ(cbm_pipeline_insert_import_edge(&base_ctx, base_file_id, base_helper, "Helper"), 1); + ASSERT_GT(cbm_gbuf_insert_edge(base_gb, base_main_id, base_helper_id, "CALLS", "{}"), 0); + + cbm_pipeline_file_delta_t base_helper_delta = {0}; + cbm_pipeline_file_delta_t base_main_delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(base_gb, project, helper_rel, generation, + &base_helper_delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(base_gb, project, main_rel, generation, + &base_main_delta), + CBM_STORE_OK); + cbm_file_info_t helper_file = {.path = (char *)helper_path, + .rel_path = (char *)helper_rel, + .language = CBM_LANG_GO}; + cbm_file_info_t main_file = { + .path = (char *)main_path, .rel_path = (char *)main_rel, .language = CBM_LANG_GO}; + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&base_helper_delta, &helper_file), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&base_main_delta, &main_file), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_publish_file_delta(s, &base_helper_delta.delta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_publish_file_delta(s, &base_main_delta.delta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + cbm_pipeline_file_delta_free(&base_helper_delta); + cbm_pipeline_file_delta_free(&base_main_delta); + cbm_gbuf_free(base_gb); + + ASSERT_EQ(th_write_file(helper_path, "package helper\nfunc NewHelper() {}\n"), 0); + ASSERT_EQ(th_write_file(main_path, + "package main\nimport \"helper\"\nfunc New() { helper.NewHelper() }\n"), + 0); + ASSERT_EQ(cbm_store_reserve_index_generation(s, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FINAL_GENERATION); + + cbm_gbuf_t *final_gb = cbm_gbuf_new(project, tmp); + ASSERT_NOT_NULL(final_gb); + int64_t new_helper_id = cbm_gbuf_upsert_node(final_gb, "Function", "NewHelper", + new_helper_qn, helper_rel, 1, 1, + "{\"is_exported\":true}"); + int64_t final_file_id = cbm_gbuf_upsert_node(final_gb, "File", main_rel, + "test.main.__file__", main_rel, 1, 1, "{}"); + int64_t new_main_id = cbm_gbuf_upsert_node(final_gb, "Function", "New", new_main_qn, + main_rel, 3, 3, "{\"is_exported\":true}"); + ASSERT_GT(new_helper_id, 0); + ASSERT_GT(final_file_id, 0); + ASSERT_GT(new_main_id, 0); + const cbm_gbuf_node_t *new_helper = cbm_gbuf_find_by_qn(final_gb, new_helper_qn); + ASSERT_NOT_NULL(new_helper); + cbm_pipeline_ctx_t final_ctx = {.project_name = project, .repo_path = tmp, .gbuf = final_gb}; + ASSERT_EQ(cbm_pipeline_insert_import_edge(&final_ctx, final_file_id, new_helper, "NewHelper"), + 1); + ASSERT_GT(cbm_gbuf_insert_edge(final_gb, new_main_id, new_helper_id, "CALLS", "{}"), 0); + + cbm_pipeline_file_delta_t final_helper_delta = {0}; + cbm_pipeline_file_delta_t final_main_delta = {0}; + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(final_gb, project, helper_rel, generation, + &final_helper_delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_build_file_delta_from_gbuf(final_gb, project, main_rel, generation, + &final_main_delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&final_helper_delta, &helper_file), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata(&final_main_delta, &main_file), + CBM_STORE_OK); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_HOST"), "localhost"); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_PORT"), "5432"); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PLAIN_VALUE"), "hello world"); + cbm_pipeline_file_delta_plan_t main_before_helper_plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &final_main_delta, + PIPELINE_DELTA_PARITY_MAX_AFFECTED, + &main_before_helper_plan), + CBM_STORE_OK); + ASSERT_EQ(main_before_helper_plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(main_before_helper_plan.reason, "unresolved_edge_endpoint"); + const cbm_pipeline_file_delta_t *main_only_deltas[] = {&final_main_delta}; + cbm_pipeline_file_delta_plan_t main_only_apply_plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, main_only_deltas, + PIPELINE_DELTA_PARITY_SINGLE_COUNT, + PIPELINE_DELTA_PARITY_MAX_AFFECTED, + &main_only_apply_plan), + CBM_STORE_OK); + ASSERT_EQ(main_only_apply_plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK); + ASSERT_STR_EQ(main_only_apply_plan.reason, "unresolved_edge_endpoint"); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_helper_qn), 1); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_main_qn), 1); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_helper_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_main_qn), 0); + + const cbm_pipeline_file_delta_t *batch_deltas[] = {&final_helper_delta, &final_main_delta}; + cbm_pipeline_file_delta_plan_t batch_plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, batch_deltas, + PIPELINE_DELTA_PARITY_BATCH_COUNT, + PIPELINE_DELTA_PARITY_MAX_AFFECTED, &batch_plan), + CBM_STORE_OK); + ASSERT_EQ(batch_plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + ASSERT_EQ(pipeline_delta_plan_contains_path(&batch_plan, helper_rel), 1); + ASSERT_EQ(pipeline_delta_plan_contains_path(&batch_plan, main_rel), 1); + + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_helper_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, old_main_qn), 0); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_helper_qn), 1); + ASSERT_EQ(pipeline_delta_store_qn_exists(s, project, new_main_qn), 1); + ASSERT_EQ(cbm_store_count_edges(s, project), EXPECTED_FINAL_EDGES); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "CALLS"), EXPECTED_FINAL_CALLS_EDGES); + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "IMPORTS"), EXPECTED_FINAL_IMPORTS_EDGES); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, project, helper_rel, &got), CBM_STORE_OK); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + ASSERT_EQ(cbm_store_get_file_state(s, project, main_rel, &got), CBM_STORE_OK); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + + char **import_paths = NULL; + int import_count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, project, new_helper_qn, &import_paths, + &import_count), + CBM_STORE_OK); + ASSERT_EQ(import_count, 1); + ASSERT_STR_EQ(import_paths[0], main_rel); + pipeline_delta_free_string_array(import_paths, import_count); + + cbm_store_t *fresh = cbm_store_open_memory(); + ASSERT_NOT_NULL(fresh); + ASSERT_EQ(cbm_store_upsert_project(fresh, project, tmp), CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_index_generation(fresh, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(fresh, project, generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_index_generation(fresh, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FINAL_GENERATION); + const cbm_store_file_delta_t *fresh_store_deltas[] = {&final_helper_delta.delta, + &final_main_delta.delta}; + ASSERT_EQ(cbm_store_publish_file_delta_batch_complete( + fresh, fresh_store_deltas, PIPELINE_DELTA_PARITY_BATCH_COUNT), + CBM_STORE_OK); - /* API_SECRET should be filtered */ - ASSERT(find_env_var(r.env_vars, r.env_count, "API_SECRET") == NULL); + const char *delta_db = TH_PATH(tmp, "delta-route.db"); + const char *fresh_db = TH_PATH(tmp, "fresh-final.db"); + ASSERT_EQ(cbm_store_dump_to_file(s, delta_db), CBM_STORE_OK); + ASSERT_EQ(cbm_store_dump_to_file(fresh, fresh_db), CBM_STORE_OK); + char diff_err[CBM_SZ_8K] = {0}; + ASSERT_EQ(cbm_test_compare_canonical_graphs(delta_db, fresh_db, project, diff_err, + sizeof(diff_err)), + 0); + + cbm_pipeline_file_delta_plan_free(&main_before_helper_plan); + cbm_pipeline_file_delta_plan_free(&main_only_apply_plan); + cbm_pipeline_file_delta_plan_free(&batch_plan); + cbm_pipeline_file_delta_free(&final_helper_delta); + cbm_pipeline_file_delta_free(&final_main_delta); + cbm_gbuf_free(final_gb); + cbm_store_close(fresh); + cbm_store_close(s); + th_cleanup(tmp); PASS(); } -TEST(infra_parse_dotenv_quoted) { - /* Port of TestParseDotenvQuotedValues */ - const char *src = "KEY1=\"quoted value\"\n" - "KEY2='single quoted'\n"; +/* ── Config helpers (pass_configures.c) ───────────────────────── */ - cbm_dotenv_result_t r; - ASSERT_EQ(cbm_parse_dotenv_source(src, &r), 0); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "KEY1"), "quoted value"); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "KEY2"), "single quoted"); +TEST(configures_is_env_var_name) { + ASSERT(cbm_is_env_var_name("DATABASE_URL")); + ASSERT(cbm_is_env_var_name("API_KEY")); + ASSERT(cbm_is_env_var_name("PORT")); + ASSERT(!cbm_is_env_var_name("A")); /* too short */ + ASSERT(!cbm_is_env_var_name("port")); /* lowercase */ + ASSERT(!cbm_is_env_var_name("apiKey")); /* camelCase */ + ASSERT(cbm_is_env_var_name("DB_2")); /* with digit */ + ASSERT(!cbm_is_env_var_name("__")); /* no uppercase */ + ASSERT(!cbm_is_env_var_name("")); /* empty */ PASS(); } -/* ── Infrascan: Shell script parser ─────────────────────────────── */ +TEST(configures_normalize_config_key) { + char norm[256]; + int tokens; -TEST(infra_parse_shell) { + tokens = cbm_normalize_config_key("max_connections", norm, sizeof(norm)); + ASSERT_STR_EQ(norm, "max_connections"); + ASSERT_EQ(tokens, 2); + + tokens = cbm_normalize_config_key("maxConnections", norm, sizeof(norm)); + ASSERT_STR_EQ(norm, "max_connections"); + ASSERT_EQ(tokens, 2); + + tokens = cbm_normalize_config_key("DATABASE_HOST", norm, sizeof(norm)); + ASSERT_STR_EQ(norm, "database_host"); + ASSERT_EQ(tokens, 2); + + tokens = cbm_normalize_config_key("database.host", norm, sizeof(norm)); + ASSERT_STR_EQ(norm, "database_host"); + ASSERT_EQ(tokens, 2); + + tokens = cbm_normalize_config_key("port", norm, sizeof(norm)); + ASSERT_STR_EQ(norm, "port"); + ASSERT_EQ(tokens, 1); + + tokens = cbm_normalize_config_key("maxRetryCount", norm, sizeof(norm)); + ASSERT_STR_EQ(norm, "max_retry_count"); + ASSERT_EQ(tokens, 3); + PASS(); +} + +TEST(configures_has_config_extension) { + ASSERT(cbm_has_config_extension("config.toml")); + ASSERT(cbm_has_config_extension("settings.yaml")); + ASSERT(cbm_has_config_extension("config.yml")); + ASSERT(cbm_has_config_extension(".env")); + ASSERT(cbm_has_config_extension("config.ini")); + ASSERT(cbm_has_config_extension("data.json")); + ASSERT(cbm_has_config_extension("pom.xml")); + ASSERT(!cbm_has_config_extension("main.go")); + ASSERT(!cbm_has_config_extension("app.py")); + ASSERT(!cbm_has_config_extension("data.csv")); + PASS(); +} + +/* ── Config integration tests (configures_test.go ports) ──────── */ + +TEST(configures_env_var_in_config) { + /* Port of TestBuildEnvIndex_ConfigVariableAdded: + * config.toml has DATABASE_URL, main.go does os.Getenv("DATABASE_URL") + * → CONFIGURES edges should link them. */ + const char *files[] = {"config.toml", "main.go"}; + const char *contents[] = {"DATABASE_URL = \"postgresql://localhost/db\"\n", + + "package main\n\n" + "import \"os\"\n\n" + "func main() {\n" + "\turl := os.Getenv(\"DATABASE_URL\")\n" + "\t_ = url\n" + "}\n"}; + if (setup_lang_repo(files, contents, 2) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + /* Verify CONFIGURES edges were created */ + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_type(s, proj, "CONFIGURES", &edges, &ec); + /* At minimum the pipeline should not crash. Edge count depends on + * extraction matching env var accesses to config variables. */ + if (edges) + cbm_store_free_edges(edges, ec); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(configures_lowercase_key_skipped) { + /* Port of TestBuildEnvIndex_LowercaseKeySkipped: + * config.toml has lowercase key — should NOT produce env var CONFIGURES edges. */ + const char *files[] = {"config.toml", "main.go"}; + const char *contents[] = {"database_host = \"localhost\"\n", + + "package main\n\nfunc main() {}\n"}; + if (setup_lang_repo(files, contents, 2) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + /* Pipeline ran successfully — no crash from lowercase config keys */ + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(configures_non_config_file_skipped) { + /* Port of TestBuildEnvIndex_NonConfigFileSkipped: + * Only Go file, no config file — no config-derived CONFIGURES edges. */ + const char *files[] = {"main.go"}; + const char *contents[] = {"package main\n\n" + "var API_URL = \"https://api.example.com\"\n\n" + "func main() {}\n"}; + if (setup_lang_repo(files, contents, 1) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + /* No config file → buildEnvIndex should not create config-derived entries */ + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(configures_full_pipeline_integration) { + /* Port of TestConfigIntegration_FullPipeline: + * TOML + INI + JSON config files + Go code → Class & Variable nodes from + * config files, plus CONFIGURES edges. */ + const char *files[] = {"config.toml", "settings.ini", "config.json", "main.go"}; + const char *contents[] = {"[database]\n" + "host = \"localhost\"\n" + "port = 5432\n" + "max_connections = 100\n\n" + "[server]\n" + "bind_address = \"0.0.0.0\"\n", + + "[database]\n" + "host = localhost\n" + "port = 5432\n", + + "{\"appName\": \"test\", \"maxRetries\": 3}", + + "package main\n\n" + "import \"os\"\n\n" + "func getMaxConnections() int { return 100 }\n\n" + "func loadConfig() {\n" + "\tcfg := readFile(\"config.toml\")\n" + "\t_ = cfg\n" + "\tdbURL := os.Getenv(\"DATABASE_URL\")\n" + "\t_ = dbURL\n" + "}\n\n" + "func readFile(path string) string { return \"\" }\n"}; + if (setup_lang_repo(files, contents, 4) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + /* Should have Class nodes (database, server sections from TOML) */ + cbm_node_t *classes = NULL; + int cc = 0; + cbm_store_find_nodes_by_label(s, proj, "Class", &classes, &cc); + ASSERT_GT(cc, 0); + if (classes) + cbm_store_free_nodes(classes, cc); + + /* Should have Variable nodes from config files */ + cbm_node_t *vars = NULL; + int vc = 0; + cbm_store_find_nodes_by_label(s, proj, "Variable", &vars, &vc); + ASSERT_GT(vc, 0); + if (vars) + cbm_store_free_nodes(vars, vc); + + /* Should have Function nodes from Go code */ + cbm_node_t *funcs = NULL; + int fc = 0; + cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc); + ASSERT_GT(fc, 0); + if (funcs) + cbm_store_free_nodes(funcs, fc); + + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} +TEST(enrichment_split_camel_case) { + char *parts[8]; + int n; + + n = cbm_split_camel_case("GetMapping", parts, 8); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(parts[0], "Get"); + ASSERT_STR_EQ(parts[1], "Mapping"); + for (int i = 0; i < n; i++) + free(parts[i]); + + n = cbm_split_camel_case("getMessage", parts, 8); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(parts[0], "get"); + ASSERT_STR_EQ(parts[1], "Message"); + for (int i = 0; i < n; i++) + free(parts[i]); + + n = cbm_split_camel_case("cache", parts, 8); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(parts[0], "cache"); + for (int i = 0; i < n; i++) + free(parts[i]); + + n = cbm_split_camel_case("HTMLParser", parts, 8); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(parts[0], "HTMLParser"); + for (int i = 0; i < n; i++) + free(parts[i]); + + n = cbm_split_camel_case("", parts, 8); + ASSERT_EQ(n, 0); + PASS(); +} + +TEST(enrichment_tokenize_decorator) { + char *tokens[16]; + int n; + + n = cbm_tokenize_decorator("@Override", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "override"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@Deprecated", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "deprecated"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@Test", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "test"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@login_required", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "login"); + ASSERT_STR_EQ(tokens[1], "required"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@cache", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "cache"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@pytest.fixture", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "pytest"); + ASSERT_STR_EQ(tokens[1], "fixture"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + /* "get" is stopword → only "mapping" */ + n = cbm_tokenize_decorator("@GetMapping(\"/api\")", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "mapping"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + /* "post" passes, "mapping" passes */ + n = cbm_tokenize_decorator("@PostMapping(\"/api\")", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "post"); + ASSERT_STR_EQ(tokens[1], "mapping"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@Transactional", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "transactional"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@MessageMapping(\"/chat\")", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "message"); + ASSERT_STR_EQ(tokens[1], "mapping"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + /* Rust-style #[test] */ + n = cbm_tokenize_decorator("#[test]", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "test"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + /* #[derive(Debug)] */ + n = cbm_tokenize_decorator("#[derive(Debug)]", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "derive"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + /* Both "app" and "get" are stopwords → empty */ + n = cbm_tokenize_decorator("@app.get(\"/api\")", tokens, 16); + ASSERT_EQ(n, 0); + + /* "router" is stopword, "post" passes */ + n = cbm_tokenize_decorator("@router.post(\"/api\")", tokens, 16); + ASSERT_EQ(n, 1); + ASSERT_STR_EQ(tokens[0], "post"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + /* Too short after filtering */ + n = cbm_tokenize_decorator("@x", tokens, 16); + ASSERT_EQ(n, 0); + + /* Empty */ + n = cbm_tokenize_decorator("", tokens, 16); + ASSERT_EQ(n, 0); + + n = cbm_tokenize_decorator("@click.command", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "click"); + ASSERT_STR_EQ(tokens[1], "command"); + for (int i = 0; i < n; i++) + free(tokens[i]); + + n = cbm_tokenize_decorator("@celery.task", tokens, 16); + ASSERT_EQ(n, 2); + ASSERT_STR_EQ(tokens[0], "celery"); + ASSERT_STR_EQ(tokens[1], "task"); + for (int i = 0; i < n; i++) + free(tokens[i]); + PASS(); +} + +/* ── Decorator tags integration tests (enrichment_test.go ports) ─ */ + +/* Helper: check if a node's properties_json contains a specific decorator_tag */ +static bool has_decorator_tag(const char *properties_json, const char *tag) { + if (!properties_json || !tag) + return false; + yyjson_doc *doc = yyjson_read(properties_json, strlen(properties_json), 0); + if (!doc) + return false; + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *tags = yyjson_obj_get(root, "decorator_tags"); + if (!tags || !yyjson_is_arr(tags)) { + yyjson_doc_free(doc); + return false; + } + yyjson_val *item; + yyjson_arr_iter iter; + yyjson_arr_iter_init(tags, &iter); + while ((item = yyjson_arr_iter_next(&iter))) { + if (yyjson_is_str(item) && strcmp(yyjson_get_str(item), tag) == 0) { + yyjson_doc_free(doc); + return true; + } + } + yyjson_doc_free(doc); + return false; +} + +TEST(decorator_tags_python_auto_discovery) { + /* Port of TestDecoratorTagAutoDiscovery: + * Python file with repeated decorators (@login_required on 2 funcs, + * @cache on 2 funcs, @unique_helper on 1 func). + * Words on 2+ nodes become tags; unique words do not. */ + const char *files[] = {"views.py"}; + const char *contents[] = {"from functools import cache\n\n" + "@login_required\n" + "def list_orders():\n" + " pass\n\n" + "@login_required\n" + "def get_order():\n" + " pass\n\n" + "@cache\n" + "def compute_total():\n" + " pass\n\n" + "@cache\n" + "def compute_tax():\n" + " pass\n\n" + "@unique_helper\n" + "def special():\n" + " pass\n"}; + if (setup_lang_repo(files, contents, 1) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + /* Find functions by name and check decorator_tags */ + cbm_node_t *funcs = NULL; + int fc = 0; + cbm_store_find_nodes_by_label(s, proj, "Function", &funcs, &fc); + + /* Build name→properties_json map */ + const char *list_orders_props = NULL; + const char *get_order_props = NULL; + const char *compute_total_props = NULL; + const char *compute_tax_props = NULL; + const char *special_props = NULL; + for (int i = 0; i < fc; i++) { + if (strcmp(funcs[i].name, "list_orders") == 0) + list_orders_props = funcs[i].properties_json; + else if (strcmp(funcs[i].name, "get_order") == 0) + get_order_props = funcs[i].properties_json; + else if (strcmp(funcs[i].name, "compute_total") == 0) + compute_total_props = funcs[i].properties_json; + else if (strcmp(funcs[i].name, "compute_tax") == 0) + compute_tax_props = funcs[i].properties_json; + else if (strcmp(funcs[i].name, "special") == 0) + special_props = funcs[i].properties_json; + } + + /* "login" and "required" appear on 2 nodes → should be tags */ + ASSERT_TRUE(has_decorator_tag(list_orders_props, "login")); + ASSERT_TRUE(has_decorator_tag(list_orders_props, "required")); + ASSERT_TRUE(has_decorator_tag(get_order_props, "login")); + ASSERT_TRUE(has_decorator_tag(get_order_props, "required")); + + /* "cache" appears on 2 nodes → should be a tag */ + ASSERT_TRUE(has_decorator_tag(compute_total_props, "cache")); + ASSERT_TRUE(has_decorator_tag(compute_tax_props, "cache")); + + /* "unique" and "helper" appear on only 1 node → should NOT be tags */ + ASSERT_FALSE(has_decorator_tag(special_props, "unique")); + ASSERT_FALSE(has_decorator_tag(special_props, "helper")); + + if (funcs) + cbm_store_free_nodes(funcs, fc); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(decorator_tags_java_class_methods) { + /* Port of TestDecoratorTagJavaClassMethods: + * Java class with @GetMapping, @PostMapping, @Transactional annotations. + * "mapping" appears on all 4 → tag. "post" on 2 → tag. */ + const char *files[] = {"Controller.java"}; + const char *contents[] = {"class OwnerController {\n" + " @GetMapping(\"/owners\")\n" + " public void listOwners() {}\n\n" + " @GetMapping(\"/owners/{id}\")\n" + " public void showOwner() {}\n\n" + " @PostMapping(\"/owners\")\n" + " public void createOwner() {}\n\n" + " @Transactional\n" + " @PostMapping(\"/owners/{id}\")\n" + " public void updateOwner() {}\n" + "}\n"}; + if (setup_lang_repo(files, contents, 1) != 0) + FAIL("tmpdir"); + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + /* Find methods */ + cbm_node_t *methods = NULL; + int mc = 0; + cbm_store_find_nodes_by_label(s, proj, "Method", &methods, &mc); + + /* "mapping" appears on all 4 methods → should be a tag */ + for (int i = 0; i < mc; i++) { + if (strcmp(methods[i].name, "listOwners") == 0 || + strcmp(methods[i].name, "showOwner") == 0 || + strcmp(methods[i].name, "createOwner") == 0 || + strcmp(methods[i].name, "updateOwner") == 0) { + ASSERT_TRUE(has_decorator_tag(methods[i].properties_json, "mapping")); + } + } + + /* "post" appears on createOwner + updateOwner → should be a tag */ + for (int i = 0; i < mc; i++) { + if (strcmp(methods[i].name, "createOwner") == 0 || + strcmp(methods[i].name, "updateOwner") == 0) { + ASSERT_TRUE(has_decorator_tag(methods[i].properties_json, "post")); + } + } + + /* "transactional" appears on only 1 method → should NOT be a tag */ + for (int i = 0; i < mc; i++) { + if (strcmp(methods[i].name, "updateOwner") == 0) { + ASSERT_FALSE(has_decorator_tag(methods[i].properties_json, "transactional")); + } + } + + if (methods) + cbm_store_free_nodes(methods, mc); + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +/* ── Compile commands helpers (pass_compile_commands.c) ────────── */ + +TEST(compile_commands_split_command) { + char *args[16]; + int n; + + n = cbm_split_command("gcc -c main.c", args, 16); + ASSERT_EQ(n, 3); + ASSERT_STR_EQ(args[0], "gcc"); + ASSERT_STR_EQ(args[1], "-c"); + ASSERT_STR_EQ(args[2], "main.c"); + for (int i = 0; i < n; i++) + free(args[i]); + + n = cbm_split_command("gcc -DFOO=\"bar baz\" -c main.c", args, 16); + ASSERT_EQ(n, 4); + for (int i = 0; i < n; i++) + free(args[i]); + + n = cbm_split_command("g++ -I/usr/include -std=c++17 -o out -c in.cpp", args, 16); + ASSERT_EQ(n, 7); + for (int i = 0; i < n; i++) + free(args[i]); + PASS(); +} + +TEST(compile_commands_extract_flags) { + const char *args[] = {"g++", "-I", "/abs/include", "-I/rel/include", "-isystem", + "/sys/include", "-DFOO", "-DBAR=42", "-std=c++20", "-O2", + "-Wall", "-c", "main.cpp"}; + + cbm_compile_flags_t *f = cbm_extract_flags(args, 13, "/project"); + ASSERT_NOT_NULL(f); + ASSERT_EQ(f->include_count, 3); + ASSERT_EQ(f->define_count, 2); + ASSERT_STR_EQ(f->standard, "c++20"); + cbm_compile_flags_free(f); + PASS(); +} + +TEST(compile_commands_parse_json) { + const char *json = "[\n" + " {\n" + " \"directory\": \"/home/user/project/build\",\n" + " \"command\": \"gcc -I/home/user/project/include " + "-I/home/user/project/src -DDEBUG=1 -DVERSION=\\\"1.0\\\" " + "-std=c11 -o main.o -c /home/user/project/src/main.c\",\n" + " \"file\": \"/home/user/project/src/main.c\"\n" + " },\n" + " {\n" + " \"directory\": \"/home/user/project/build\",\n" + " \"arguments\": [\"g++\", \"-I/home/user/project/include\", " + "\"-isystem\", \"/home/user/project/third_party\", " + "\"-DUSE_SSL\", \"-std=c++17\", \"-c\", " + "\"/home/user/project/src/server.cpp\"],\n" + " \"file\": \"/home/user/project/src/server.cpp\"\n" + " },\n" + " {\n" + " \"directory\": \"/home/user/project/build\",\n" + " \"command\": \"gcc -c /outside/repo/file.c\",\n" + " \"file\": \"/outside/repo/file.c\"\n" + " }\n" + "]"; + + char **paths = NULL; + cbm_compile_flags_t **flags = NULL; + int n = cbm_parse_compile_commands(json, "/home/user/project", &paths, &flags); + ASSERT(n >= 2); /* At least main.c and server.cpp, outside file excluded */ + + /* Find main.c */ + int main_idx = -1, server_idx = -1; + for (int i = 0; i < n; i++) { + if (strcmp(paths[i], "src/main.c") == 0) + main_idx = i; + if (strcmp(paths[i], "src/server.cpp") == 0) + server_idx = i; + } + + ASSERT(main_idx >= 0); + ASSERT_EQ(flags[main_idx]->include_count, 2); + ASSERT_EQ(flags[main_idx]->define_count, 2); + ASSERT_STR_EQ(flags[main_idx]->standard, "c11"); + + ASSERT(server_idx >= 0); + ASSERT_EQ(flags[server_idx]->include_count, 2); + ASSERT_EQ(flags[server_idx]->define_count, 1); + ASSERT_STR_EQ(flags[server_idx]->standard, "c++17"); + + /* Verify outside-repo file excluded */ + for (int i = 0; i < n; i++) { + ASSERT(strstr(paths[i], "outside") == NULL); + } + + /* Cleanup */ + for (int i = 0; i < n; i++) { + free(paths[i]); + cbm_compile_flags_free(flags[i]); + } + free(paths); + free(flags); + PASS(); +} + +TEST(compile_commands_parse_empty) { + char **paths = NULL; + cbm_compile_flags_t **flags = NULL; + int n = cbm_parse_compile_commands("[]", "/repo", &paths, &flags); + ASSERT_EQ(n, 0); + free(paths); + free(flags); + PASS(); +} + +TEST(compile_commands_parse_invalid) { + char **paths = NULL; + cbm_compile_flags_t **flags = NULL; + int n = cbm_parse_compile_commands("not json", "/repo", &paths, &flags); + ASSERT(n < 0); + PASS(); +} + +/* ── Suite ─────────────────────────────────────────────────────── */ + +/* ── Infrascan: file identification ──────────────────────────────── */ + +TEST(infra_is_compose_file) { + /* Port of TestIsComposeFile (8 cases) */ + ASSERT(cbm_is_compose_file("docker-compose.yml")); + ASSERT(cbm_is_compose_file("docker-compose.yaml")); + ASSERT(cbm_is_compose_file("docker-compose.prod.yml")); + ASSERT(cbm_is_compose_file("compose.yml")); + ASSERT(cbm_is_compose_file("compose.yaml")); + ASSERT(!cbm_is_compose_file("mycompose.yml")); + ASSERT(!cbm_is_compose_file("docker-compose.txt")); + ASSERT(!cbm_is_compose_file("Dockerfile")); + PASS(); +} + +TEST(infra_is_cloudbuild_file) { + /* Port of TestIsCloudbuildFile (5 cases) */ + ASSERT(cbm_is_cloudbuild_file("cloudbuild.yaml")); + ASSERT(cbm_is_cloudbuild_file("cloudbuild.yml")); + ASSERT(cbm_is_cloudbuild_file("cloudbuild-prod.yaml")); + ASSERT(cbm_is_cloudbuild_file("Cloudbuild.yml")); + ASSERT(!cbm_is_cloudbuild_file("build.yaml")); + PASS(); +} + +TEST(infra_is_shell_script) { + /* Port of TestIsShellScript (5 cases) */ + ASSERT(cbm_is_shell_script("run.sh", ".sh")); + ASSERT(cbm_is_shell_script("deploy.bash", ".bash")); + ASSERT(cbm_is_shell_script("init.zsh", ".zsh")); + ASSERT(!cbm_is_shell_script("main.py", ".py")); + ASSERT(!cbm_is_shell_script("Dockerfile", "")); + PASS(); +} + +TEST(infra_is_dockerfile) { + ASSERT(cbm_is_dockerfile("Dockerfile")); + ASSERT(cbm_is_dockerfile("dockerfile")); + ASSERT(cbm_is_dockerfile("Dockerfile.prod")); + ASSERT(cbm_is_dockerfile("app.dockerfile")); + ASSERT(!cbm_is_dockerfile("docker-compose.yml")); + ASSERT(!cbm_is_dockerfile("main.go")); + PASS(); +} + +TEST(infra_is_kustomize_file) { + ASSERT(cbm_is_kustomize_file("kustomization.yaml")); + ASSERT(cbm_is_kustomize_file("kustomization.yml")); + ASSERT(cbm_is_kustomize_file("KUSTOMIZATION.YAML")); /* case-insensitive */ + ASSERT(!cbm_is_kustomize_file("deployment.yaml")); + ASSERT(!cbm_is_kustomize_file("kustomize.yaml")); + ASSERT(!cbm_is_kustomize_file(NULL)); + PASS(); +} + +TEST(infra_is_k8s_manifest) { + const char *deploy = "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n"; + const char *plain = "name: foo\nvalue: bar\n"; + const char *kust = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n"; + + ASSERT(cbm_is_k8s_manifest("deployment.yaml", deploy)); + ASSERT(!cbm_is_k8s_manifest("deployment.yaml", plain)); + /* kustomize file should return false even if it has apiVersion */ + ASSERT(!cbm_is_k8s_manifest("kustomization.yaml", kust)); + ASSERT(!cbm_is_k8s_manifest(NULL, deploy)); + ASSERT(!cbm_is_k8s_manifest("deployment.yaml", NULL)); + PASS(); +} + +TEST(infra_is_env_file) { + ASSERT(cbm_is_env_file(".env")); + ASSERT(cbm_is_env_file(".env.local")); + ASSERT(cbm_is_env_file("prod.env")); + ASSERT(!cbm_is_env_file("main.go")); + ASSERT(!cbm_is_env_file("env.txt")); + PASS(); +} + +/* ── K8s extraction tests ───────────────────────────────────────── */ + +TEST(k8s_extract_kustomize) { + const char *src = + "apiVersion: kustomize.config.k8s.io/v1beta1\n" + "kind: Kustomization\n" + "resources:\n" + " - deployment.yaml\n" + " - service.yaml\n"; + CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_KUSTOMIZE, + "myproj", "base/kustomization.yaml", + 0, NULL, NULL); + ASSERT(r != NULL); + ASSERT_GTE(r->imports.count, 2); + + bool found_deploy = false, found_svc = false; + for (int i = 0; i < r->imports.count; i++) { + if (r->imports.items[i].module_path && + strcmp(r->imports.items[i].module_path, "deployment.yaml") == 0) + found_deploy = true; + if (r->imports.items[i].module_path && + strcmp(r->imports.items[i].module_path, "service.yaml") == 0) + found_svc = true; + } + ASSERT_TRUE(found_deploy); + ASSERT_TRUE(found_svc); + + cbm_free_result(r); + PASS(); +} + +TEST(k8s_extract_manifest) { + const char *src = + "apiVersion: apps/v1\n" + "kind: Deployment\n" + "metadata:\n" + " name: my-app\n" + " namespace: production\n"; + CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, + "myproj", "k8s/deployment.yaml", + 0, NULL, NULL); + ASSERT(r != NULL); + ASSERT_GTE(r->defs.count, 1); + + bool found_resource = false; + for (int d = 0; d < r->defs.count; d++) { + if (r->defs.items[d].label && + strcmp(r->defs.items[d].label, "Resource") == 0 && + r->defs.items[d].name && + strstr(r->defs.items[d].name, "Deployment") != NULL) + found_resource = true; + } + ASSERT_TRUE(found_resource); + + cbm_free_result(r); + PASS(); +} + +TEST(k8s_extract_manifest_no_name) { + const char *src = "apiVersion: apps/v1\nkind: Deployment\n"; + CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, + "myproj", "k8s/deploy.yaml", 0, NULL, NULL); + ASSERT(r != NULL); + /* No crash — defs count may be 0 because metadata.name is absent */ + ASSERT(!r->has_error); + cbm_free_result(r); + PASS(); +} + +TEST(k8s_extract_manifest_multidoc) { + /* Two-document YAML separated by "---". + * extract_k8s_manifest contains a "break" after the first successful push, + * so it processes only the first document that has both kind and + * metadata.name. This test pins that behaviour: the first document's + * resource must be present and no crash must occur. */ + const char *src = + "apiVersion: apps/v1\n" + "kind: Deployment\n" + "metadata:\n" + " name: my-app\n" + "---\n" + "apiVersion: v1\n" + "kind: Service\n" + "metadata:\n" + " name: my-svc\n"; + CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, + "myproj", "k8s/multi.yaml", 0, NULL, NULL); + ASSERT(r != NULL); + ASSERT(!r->has_error); + /* First document's resource must be present */ + int found = 0; + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Resource") == 0 && + r->defs.items[i].name && strcmp(r->defs.items[i].name, "Deployment/my-app") == 0) { + found = 1; + } + } + ASSERT(found); + ASSERT(r->defs.count >= 1); + cbm_free_result(r); + PASS(); +} + +static void k8s_selector_test_ctx(cbm_pipeline_ctx_t *ctx, cbm_gbuf_t *gbuf, + atomic_int *cancelled, const char *repo_path) { + atomic_init(cancelled, 0); + *ctx = (cbm_pipeline_ctx_t){.project_name = "k8s-selector-test", + .repo_path = repo_path, + .gbuf = gbuf, + .cancelled = cancelled, + .mode = CBM_MODE_FAST}; +} + +TEST(k8s_selector_links_manifests_after_former_record_limit) { + enum { + K8S_TEST_FORMER_RECORD_LIMIT = CBM_SZ_512, + K8S_TEST_FILE_COUNT = K8S_TEST_FORMER_RECORD_LIMIT + CBM_SZ_2 + }; + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_k8s_records_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + + char filler_path[CBM_SZ_512]; + char service_path[CBM_SZ_512]; + char workload_path[CBM_SZ_512]; + snprintf(filler_path, sizeof(filler_path), "%s/filler.yaml", tmp); + snprintf(service_path, sizeof(service_path), "%s/service.yaml", tmp); + snprintf(workload_path, sizeof(workload_path), "%s/workload.yaml", tmp); + ASSERT_EQ(th_write_file(filler_path, + "apiVersion: apps/v1\n" + "kind: Deployment\n" + "metadata:\n" + " name: filler\n" + "spec:\n" + " template:\n" + " metadata:\n" + " labels:\n" + " app: filler\n"), + 0); + ASSERT_EQ(th_write_file(service_path, + "apiVersion: v1\n" + "kind: Service\n" + "metadata:\n" + " name: after-former-limit\n" + "spec:\n" + " selector:\n" + " app: after-former-limit\n"), + 0); + ASSERT_EQ(th_write_file(workload_path, + "apiVersion: apps/v1\n" + "kind: Deployment\n" + "metadata:\n" + " name: after-former-limit\n" + "spec:\n" + " template:\n" + " metadata:\n" + " labels:\n" + " app: after-former-limit\n"), + 0); + + cbm_file_info_t *files = calloc(K8S_TEST_FILE_COUNT, sizeof(*files)); + char(*rel_paths)[CBM_SZ_64] = calloc(K8S_TEST_FILE_COUNT, sizeof(*rel_paths)); + ASSERT_NOT_NULL(files); + ASSERT_NOT_NULL(rel_paths); + for (int i = 0; i < K8S_TEST_FORMER_RECORD_LIMIT; i++) { + snprintf(rel_paths[i], sizeof(rel_paths[i]), "filler-%d.yaml", i); + files[i] = (cbm_file_info_t){.path = filler_path, + .rel_path = rel_paths[i], + .language = CBM_LANG_YAML}; + } + snprintf(rel_paths[K8S_TEST_FORMER_RECORD_LIMIT], + sizeof(rel_paths[K8S_TEST_FORMER_RECORD_LIMIT]), "service.yaml"); + files[K8S_TEST_FORMER_RECORD_LIMIT] = + (cbm_file_info_t){.path = service_path, + .rel_path = rel_paths[K8S_TEST_FORMER_RECORD_LIMIT], + .language = CBM_LANG_YAML}; + snprintf(rel_paths[K8S_TEST_FORMER_RECORD_LIMIT + SKIP_ONE], + sizeof(rel_paths[K8S_TEST_FORMER_RECORD_LIMIT + SKIP_ONE]), "workload.yaml"); + files[K8S_TEST_FORMER_RECORD_LIMIT + SKIP_ONE] = + (cbm_file_info_t){.path = workload_path, + .rel_path = rel_paths[K8S_TEST_FORMER_RECORD_LIMIT + SKIP_ONE], + .language = CBM_LANG_YAML}; + + cbm_gbuf_t *gbuf = cbm_gbuf_new("k8s-selector-test", tmp); + ASSERT_NOT_NULL(gbuf); + atomic_int cancelled; + cbm_pipeline_ctx_t ctx; + k8s_selector_test_ctx(&ctx, gbuf, &cancelled, tmp); + ASSERT_EQ(cbm_pipeline_pass_k8s(&ctx, files, K8S_TEST_FILE_COUNT), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_type(gbuf, "INFRA_MAPS", &edges, &edge_count), 0); + ASSERT_EQ(edge_count, 1); + const cbm_gbuf_node_t *source = cbm_gbuf_find_by_id(gbuf, edges[0]->source_id); + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(gbuf, edges[0]->target_id); + ASSERT_NOT_NULL(source); + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(source->name, "Service/after-former-limit"); + ASSERT_STR_EQ(target->name, "Deployment/after-former-limit"); + + cbm_gbuf_free(gbuf); + free(rel_paths); + free(files); + th_cleanup(tmp); + PASS(); +} + +TEST(k8s_selector_requires_every_key_value_pair_beyond_former_pair_limit) { + enum { K8S_TEST_SELECTOR_PAIRS = CBM_SZ_16 + SKIP_ONE }; + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_k8s_pairs_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + + char service[CBM_SZ_4K]; + char partial[CBM_SZ_4K]; + char complete[CBM_SZ_4K]; + int service_len = + snprintf(service, sizeof(service), + "apiVersion: v1\nkind: Service\nmetadata:\n name: exact-selector\nspec:\n" + " selector:\n"); + int partial_len = + snprintf(partial, sizeof(partial), + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app-target\nspec:\n" + " template:\n metadata:\n labels:\n"); + int complete_len = + snprintf(complete, sizeof(complete), + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: complete\nspec:\n" + " template:\n metadata:\n labels:\n"); + ASSERT_GT(service_len, 0); + ASSERT_GT(partial_len, 0); + ASSERT_GT(complete_len, 0); + for (int i = 0; i < K8S_TEST_SELECTOR_PAIRS; i++) { + const char *key = i == K8S_TEST_SELECTOR_PAIRS - SKIP_ONE ? "app" : NULL; + const char *value = i == K8S_TEST_SELECTOR_PAIRS - SKIP_ONE ? "app-target" : NULL; + char generated_key[CBM_SZ_32]; + char generated_value[CBM_SZ_32]; + if (!key) { + snprintf(generated_key, sizeof(generated_key), "selector-%02d", i); + snprintf(generated_value, sizeof(generated_value), "value-%02d", i); + key = generated_key; + value = generated_value; + } + int n = snprintf(service + service_len, sizeof(service) - (size_t)service_len, + " %s: %s\n", key, value); + ASSERT_GT(n, 0); + service_len += n; + n = snprintf(complete + complete_len, sizeof(complete) - (size_t)complete_len, + " %s: %s\n", key, value); + ASSERT_GT(n, 0); + complete_len += n; + if (i < K8S_TEST_SELECTOR_PAIRS - SKIP_ONE) { + n = snprintf(partial + partial_len, sizeof(partial) - (size_t)partial_len, + " %s: %s\n", key, value); + ASSERT_GT(n, 0); + partial_len += n; + } + } + int n = snprintf(partial + partial_len, sizeof(partial) - (size_t)partial_len, + " app: conflicting-label\n"); + ASSERT_GT(n, 0); + partial_len += n; + + const char *names[] = {"service.yaml", "partial.yaml", "complete.yaml"}; + const char *sources[] = {service, partial, complete}; + cbm_file_info_t files[CBM_SZ_3] = {0}; + char paths[CBM_SZ_3][CBM_SZ_512]; + for (int i = 0; i < CBM_SZ_3; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmp, names[i]); + ASSERT_EQ(th_write_file(paths[i], sources[i]), 0); + files[i] = (cbm_file_info_t){ + .path = paths[i], .rel_path = (char *)names[i], .language = CBM_LANG_YAML}; + } + + cbm_gbuf_t *gbuf = cbm_gbuf_new("k8s-selector-test", tmp); + ASSERT_NOT_NULL(gbuf); + atomic_int cancelled; + cbm_pipeline_ctx_t ctx; + k8s_selector_test_ctx(&ctx, gbuf, &cancelled, tmp); + ASSERT_EQ(cbm_pipeline_pass_k8s(&ctx, files, CBM_SZ_3), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_type(gbuf, "INFRA_MAPS", &edges, &edge_count), 0); + ASSERT_EQ(edge_count, 1); + const cbm_gbuf_node_t *source = cbm_gbuf_find_by_id(gbuf, edges[0]->source_id); + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(gbuf, edges[0]->target_id); + ASSERT_NOT_NULL(source); + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(source->name, "Service/exact-selector"); + ASSERT_STR_EQ(target->name, "Deployment/complete"); + + cbm_gbuf_free(gbuf); + th_cleanup(tmp); + PASS(); +} + +/* ── Infrascan: cleanJSONBrackets ───────────────────────────────── */ + +TEST(infra_clean_json_brackets) { + /* Port of TestCleanJSONBrackets (4 cases) */ + char out[256]; + + cbm_clean_json_brackets("[\"./server\"]", out, sizeof(out)); + ASSERT_STR_EQ(out, "./server"); + + cbm_clean_json_brackets("[\"python\", \"main.py\"]", out, sizeof(out)); + ASSERT_STR_EQ(out, "python main.py"); + + cbm_clean_json_brackets("./server", out, sizeof(out)); + ASSERT_STR_EQ(out, "./server"); + + cbm_clean_json_brackets("[\"./app\", \"--flag\", \"value\"]", out, sizeof(out)); + ASSERT_STR_EQ(out, "./app --flag value"); + + PASS(); +} + +/* ── Infrascan: secret detection ────────────────────────────────── */ + +TEST(infra_secret_detection) { + /* Key-based detection */ + ASSERT(cbm_is_secret_binding("JWT_SECRET", "anything")); + ASSERT(cbm_is_secret_binding("API_KEY", "anything")); + ASSERT(cbm_is_secret_binding("my_password", "anything")); + ASSERT(cbm_is_secret_binding("AUTH_TOKEN", "anything")); + ASSERT(!cbm_is_secret_binding("DATABASE_URL", "https://db.example.com")); + + /* Value-based detection */ + ASSERT(cbm_is_secret_value("sk-1234567890abcdef12345")); + ASSERT(cbm_is_secret_value("-----BEGIN RSA PRIVATE KEY-----")); + ASSERT(!cbm_is_secret_value("https://db.example.com")); + ASSERT(!cbm_is_secret_value("hello world")); + ASSERT(!cbm_is_secret_value("8080")); + + /* isSecretBinding checks both */ + ASSERT(cbm_is_secret_binding("ANYTHING", "sk-1234567890abcdef12345")); + ASSERT(!cbm_is_secret_binding("PORT", "8080")); + + PASS(); +} + +/* ── Infrascan: Dockerfile parser ───────────────────────────────── */ + +/* Helper: find env var by key in result */ +static const char *find_env_var(const cbm_env_kv_t *vars, int count, const char *key) { + for (int i = 0; i < count; i++) { + if (strcmp(vars[i].key, key) == 0) + return vars[i].value; + } + return NULL; +} + +/* Helper: check if string array contains value */ +static bool str_array_contains(const char (*arr)[32], int count, const char *val) { + for (int i = 0; i < count; i++) { + if (strcmp(arr[i], val) == 0) + return true; + } + return false; +} + +static bool str_array_128_contains(const char (*arr)[128], int count, const char *val) { + for (int i = 0; i < count; i++) { + if (strcmp(arr[i], val) == 0) + return true; + } + return false; +} + +static bool str_array_256_contains(const char (*arr)[256], int count, const char *val) { + for (int i = 0; i < count; i++) { + if (strcmp(arr[i], val) == 0) + return true; + } + return false; +} + +TEST(infra_parse_dockerfile_multistage) { + /* Port of TestParseDockerfile "multi-stage with all directives" */ + const char *src = "FROM golang:1.23-alpine AS builder\n" + "WORKDIR /app\n" + "ARG SSH_PRIVATE_KEY\n" + "RUN go build -o server .\n" + "\n" + "FROM alpine:3.19\n" + "WORKDIR /usr/app\n" + "ENV PORT=8080\n" + "ENV PYTHONUNBUFFERED=1\n" + "EXPOSE 8080 443\n" + "USER appuser\n" + "CMD [\"./server\"]\n" + "HEALTHCHECK CMD wget http://localhost:8080/health\n"; + + cbm_dockerfile_result_t r; + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); + ASSERT_STR_EQ(r.base_image, "alpine:3.19"); + ASSERT_EQ(r.stage_count, 2); + ASSERT_STR_EQ(r.stage_images[0], "golang:1.23-alpine"); + ASSERT_STR_EQ(r.stage_images[1], "alpine:3.19"); + ASSERT(str_array_contains(r.exposed_ports, r.port_count, "8080")); + ASSERT(str_array_contains(r.exposed_ports, r.port_count, "443")); + ASSERT_STR_EQ(r.workdir, "/usr/app"); + ASSERT_STR_EQ(r.user, "appuser"); + ASSERT_STR_EQ(r.cmd, "./server"); + ASSERT_STR_EQ(r.healthcheck, "wget http://localhost:8080/health"); + + ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "PORT")); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PORT"), "8080"); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PYTHONUNBUFFERED"), "1"); + + ASSERT(str_array_128_contains(r.build_args, r.build_arg_count, "SSH_PRIVATE_KEY")); + + PASS(); +} + +TEST(infra_parse_dockerfile_entrypoint) { + /* Port of TestParseDockerfile "single stage with entrypoint" */ + const char *src = "FROM python:3.9-slim\n" + "ENTRYPOINT [\"python\", \"main.py\"]\n"; + + cbm_dockerfile_result_t r; + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); + ASSERT_STR_EQ(r.base_image, "python:3.9-slim"); + ASSERT_STR_EQ(r.entrypoint, "python main.py"); + ASSERT_EQ(r.stage_count, 1); + PASS(); +} + +TEST(infra_parse_dockerfile_secret_filtered) { + /* Port of TestParseDockerfile "secret env vars filtered" */ + const char *src = "FROM node:20\n" + "ENV API_KEY=sk-1234567890abcdef12345\n" + "ENV DATABASE_URL=https://db.example.com\n" + "ENV JWT_SECRET=supersecret\n"; + + cbm_dockerfile_result_t r; + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); + + /* API_KEY and JWT_SECRET should be filtered */ + ASSERT(find_env_var(r.env_vars, r.env_count, "API_KEY") == NULL); + ASSERT(find_env_var(r.env_vars, r.env_count, "JWT_SECRET") == NULL); + + /* DATABASE_URL should remain */ + ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "DATABASE_URL")); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"), "https://db.example.com"); + PASS(); +} + +TEST(infra_parse_dockerfile_expose_protocol) { + /* Port of TestParseDockerfile "expose with protocol suffix" */ + const char *src = "FROM nginx:latest\n" + "EXPOSE 80/tcp 443/tcp\n"; + + cbm_dockerfile_result_t r; + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); + ASSERT(str_array_contains(r.exposed_ports, r.port_count, "80")); + ASSERT(str_array_contains(r.exposed_ports, r.port_count, "443")); + PASS(); +} + +TEST(infra_parse_dockerfile_env_space) { + /* Port of TestParseDockerfile "ENV space-separated format" */ + const char *src = "FROM python:3.9\n" + "ENV PYTHONPATH /usr/app\n"; + + cbm_dockerfile_result_t r; + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r), 0); + ASSERT_NOT_NULL(find_env_var(r.env_vars, r.env_count, "PYTHONPATH")); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PYTHONPATH"), "/usr/app"); + PASS(); +} + +TEST(infra_parse_dockerfile_empty) { + /* Port of TestParseDockerfileEmpty */ + cbm_dockerfile_result_t r; + ASSERT_EQ(cbm_parse_dockerfile_source("# just a comment\n", &r), -1); + PASS(); +} + +/* ── Infrascan: Dotenv parser ───────────────────────────────────── */ + +TEST(infra_parse_dotenv) { + /* Port of TestParseDotenvFile */ + const char *src = "# Database config\n" + "DATABASE_HOST=localhost\n" + "DATABASE_PORT=5432\n" + "DATABASE_NAME=mydb\n" + "API_SECRET=should-not-appear\n" + "PLAIN_VALUE=hello world\n"; + + cbm_dotenv_result_t r; + ASSERT_EQ(cbm_parse_dotenv_source(src, &r), 0); + + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_HOST"), "localhost"); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_PORT"), "5432"); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "PLAIN_VALUE"), "hello world"); + + /* API_SECRET should be filtered */ + ASSERT(find_env_var(r.env_vars, r.env_count, "API_SECRET") == NULL); + PASS(); +} + +TEST(infra_parse_dotenv_quoted) { + /* Port of TestParseDotenvQuotedValues */ + const char *src = "KEY1=\"quoted value\"\n" + "KEY2='single quoted'\n"; + + cbm_dotenv_result_t r; + ASSERT_EQ(cbm_parse_dotenv_source(src, &r), 0); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "KEY1"), "quoted value"); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "KEY2"), "single quoted"); + PASS(); +} + +/* ── Infrascan: Shell script parser ─────────────────────────────── */ + +TEST(infra_parse_shell) { /* Port of TestParseShellScript */ const char *src = "#!/bin/bash\n" "set -e\n" @@ -4290,2773 +9244,10755 @@ TEST(infra_parse_shell) { "# Shut down existing containers\n" "./shut-down-docker-container.sh\n" "\n" - "docker build -t \"$YOUR_CONTAINER_NAME\" \"$DOCKERFILE_PATH\"\n" - "docker run -d --name \"$YOUR_CONTAINER_NAME\" \"$YOUR_CONTAINER_NAME\"\n" - "docker-compose up -d\n"; + "docker build -t \"$YOUR_CONTAINER_NAME\" \"$DOCKERFILE_PATH\"\n" + "docker run -d --name \"$YOUR_CONTAINER_NAME\" \"$YOUR_CONTAINER_NAME\"\n" + "docker-compose up -d\n"; + + cbm_shell_result_t r; + ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); + ASSERT_STR_EQ(r.shebang, "/bin/bash"); + + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "ENVIRONMENT"), "development"); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "YOUR_CONTAINER_NAME"), + "order-email-extractor-endpoint"); + + ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker build")); + ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker run")); + ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker-compose up")); + + PASS(); +} + +TEST(infra_parse_shell_with_source) { + /* Port of TestParseShellScriptWithSource */ + const char *src = "#!/usr/bin/env bash\n" + "source ./config.sh\n" + ". /etc/profile.d/env.sh\n"; + + cbm_shell_result_t r; + ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); + ASSERT_STR_EQ(r.shebang, "/usr/bin/env bash"); + ASSERT(str_array_256_contains(r.sources, r.source_count, "./config.sh")); + ASSERT(str_array_256_contains(r.sources, r.source_count, "/etc/profile.d/env.sh")); + PASS(); +} + +TEST(infra_parse_shell_secret_filtered) { + /* Port of TestParseShellScriptSecretFiltered */ + const char *src = "#!/bin/bash\n" + "export API_SECRET=\"should-not-appear\"\n" + "export DATABASE_URL=\"https://db.example.com\"\n"; + + cbm_shell_result_t r; + ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); + ASSERT(find_env_var(r.env_vars, r.env_count, "API_SECRET") == NULL); + ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"), "https://db.example.com"); + PASS(); +} + +TEST(infra_parse_shell_shebang_only) { + /* Port of TestParseShellScriptShebanOnly */ + const char *src = "#!/bin/bash\n# just comments\n"; + cbm_shell_result_t r; + ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); + ASSERT_STR_EQ(r.shebang, "/bin/bash"); + PASS(); +} + +TEST(infra_parse_shell_truly_empty) { + /* Port of TestParseShellScriptTrulyEmpty */ + cbm_shell_result_t r; + ASSERT_EQ(cbm_parse_shell_source("# no shebang, just comments\n", &r), -1); + PASS(); +} + +/* ── Infrascan: Terraform parser ────────────────────────────────── */ + +TEST(infra_parse_terraform_full) { + /* Port of TestParseTerraformFile */ + const char *src = "\n" + "terraform {\n" + " required_providers {\n" + " google = {\n" + " source = \"hashicorp/google\"\n" + " version = \"~> 6.35.0\"\n" + " }\n" + " }\n" + " backend \"gcs\" {\n" + " bucket = \"example-tf\"\n" + " prefix = \"state\"\n" + " }\n" + "}\n" + "\n" + "variable \"project_id\" {\n" + " description = \"The GCP project ID\"\n" + " type = string\n" + " default = \"example-cloud\"\n" + "}\n" + "\n" + "variable \"region\" {\n" + " description = \"The region\"\n" + " type = string\n" + "}\n" + "\n" + "resource \"google_cloud_run_service\" \"main\" {\n" + " name = \"my-service\"\n" + " location = var.region\n" + "}\n" + "\n" + "resource \"google_compute_address\" \"nat_ip\" {\n" + " name = \"nat-ip\"\n" + " region = var.region\n" + "}\n" + "\n" + "output \"service_url\" {\n" + " value = google_cloud_run_service.main.status[0].url\n" + "}\n" + "\n" + "data \"google_project\" \"project\" {\n" + "}\n" + "\n" + "module \"vpc\" {\n" + " source = \"./modules/vpc\"\n" + "}\n" + "\n" + "locals {\n" + " env = \"prod\"\n" + "}\n"; + + cbm_terraform_result_t r; + ASSERT_EQ(cbm_parse_terraform_source(src, &r), 0); + ASSERT_STR_EQ(r.backend, "gcs"); + + /* Resources */ + ASSERT_EQ(r.resource_count, 2); + bool found_cloud_run = false; + for (int i = 0; i < r.resource_count; i++) { + if (strcmp(r.resources[i].type, "google_cloud_run_service") == 0 && + strcmp(r.resources[i].name, "main") == 0) { + found_cloud_run = true; + } + } + ASSERT(found_cloud_run); + + /* Variables */ + ASSERT_EQ(r.variable_count, 2); + bool found_project_id = false; + for (int i = 0; i < r.variable_count; i++) { + if (strcmp(r.variables[i].name, "project_id") == 0) { + ASSERT_STR_EQ(r.variables[i].default_val, "example-cloud"); + ASSERT_STR_EQ(r.variables[i].type, "string"); + ASSERT_STR_EQ(r.variables[i].description, "The GCP project ID"); + found_project_id = true; + } + } + ASSERT(found_project_id); + + /* Outputs */ + ASSERT_EQ(r.output_count, 1); + ASSERT_STR_EQ(r.outputs[0], "service_url"); + + /* Data sources */ + ASSERT_EQ(r.data_source_count, 1); + ASSERT_STR_EQ(r.data_sources[0].type, "google_project"); + ASSERT_STR_EQ(r.data_sources[0].name, "project"); + + /* Modules */ + ASSERT_EQ(r.module_count, 1); + ASSERT_STR_EQ(r.modules[0].tf_name, "vpc"); + ASSERT_STR_EQ(r.modules[0].source, "./modules/vpc"); + + /* Locals */ + ASSERT(r.has_locals); + + PASS(); +} + +TEST(infra_parse_terraform_variables_only) { + /* Port of TestParseTerraformVariablesOnly — secret default filtered */ + const char *src = "\n" + "variable \"project_id\" {\n" + " description = \"The GCP project ID\"\n" + " type = string\n" + " default = \"example-cloud\"\n" + "}\n" + "\n" + "variable \"secret_key\" {\n" + " description = \"A secret\"\n" + " type = string\n" + " default = \"sk-1234567890abcdef12345\"\n" + "}\n"; + + cbm_terraform_result_t r; + ASSERT_EQ(cbm_parse_terraform_source(src, &r), 0); + ASSERT_EQ(r.variable_count, 2); + + /* secret_key default should be filtered */ + for (int i = 0; i < r.variable_count; i++) { + if (strcmp(r.variables[i].name, "secret_key") == 0) { + ASSERT_STR_EQ(r.variables[i].default_val, ""); + } + } + PASS(); +} + +TEST(infra_parse_terraform_empty) { + /* Port of TestParseTerraformEmpty */ + cbm_terraform_result_t r; + ASSERT_EQ(cbm_parse_terraform_source("# just comments\n", &r), -1); + PASS(); +} + +/* ── Helm Chart.yaml dependency parsing (#338) ──────────────────── */ + +TEST(helm_parse_chart_dependencies_issue338) { + const char *src = "apiVersion: v2\n" + "name: mychart\n" + "version: 1.0.0\n" + "dependencies:\n" + " - name: postgresql\n" + " repository: https://charts.bitnami.com/bitnami\n" + " version: 12.x.x\n" + " - name: redis\n" + " repository: https://charts.bitnami.com/bitnami\n" + "maintainers:\n" + " - name: alice\n"; /* not a dependency — outside the block */ + cbm_helm_chart_t hc; + ASSERT_EQ(cbm_parse_helm_chart(src, &hc), 0); + ASSERT_STR_EQ(hc.chart_name, "mychart"); + ASSERT_EQ(hc.dep_count, 2); + ASSERT_STR_EQ(hc.deps[0], "postgresql"); + ASSERT_STR_EQ(hc.deps[1], "redis"); + PASS(); +} + +TEST(helm_parse_chart_no_deps_issue338) { + cbm_helm_chart_t hc; + ASSERT_EQ(cbm_parse_helm_chart("name: solo\nversion: 0.1.0\n", &hc), 0); + ASSERT_STR_EQ(hc.chart_name, "solo"); + ASSERT_EQ(hc.dep_count, 0); + PASS(); +} + +/* ── Infrascan: infra QN helper ─────────────────────────────────── */ + +/* ── Function Registry / Resolver tests ─────────────────────────── */ + +TEST(registry_resolve_single_candidate) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); + cbm_registry_add(reg, "ValidateOrder", "svcB.validators.ValidateOrder", "Function"); + + /* Normal resolve unique name */ + cbm_resolution_t r = cbm_registry_resolve(reg, "CreateOrder", "svcC.caller", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "svcA.handlers.CreateOrder"); + + /* Fuzzy resolve with unknown prefix */ + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "unknownPkg.CreateOrder", "svcC.caller", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT_STR_EQ(fr.result.qualified_name, "svcA.handlers.CreateOrder"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_nonexistent) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); + + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "NonExistent", "svcC.caller", NULL, NULL, 0); + ASSERT_FALSE(fr.ok); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_multiple_best_by_distance) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Process", "svcA.handlers.Process", "Function"); + cbm_registry_add(reg, "Process", "svcB.handlers.Process", "Function"); + + /* Caller in svcA → prefer svcA */ + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "unknown.Process", "svcA.other", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT_STR_EQ(fr.result.qualified_name, "svcA.handlers.Process"); + + /* Caller in svcB → prefer svcB */ + fr = cbm_registry_fuzzy_resolve(reg, "unknown.Process", "svcB.other", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT_STR_EQ(fr.result.qualified_name, "svcB.handlers.Process"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_simple_name_extraction) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "DoWork", "myproject.utils.DoWork", "Function"); + + /* Deeply qualified name → extract "DoWork" */ + cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "some.deep.module.DoWork", + "myproject.caller", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT_STR_EQ(fr.result.qualified_name, "myproject.utils.DoWork"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_empty) { + cbm_registry_t *reg = cbm_registry_new(); + + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "SomeFunc", "myproject.caller", NULL, NULL, 0); + ASSERT_FALSE(fr.ok); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_exists) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Foo", "pkg.module.Foo", "Function"); + cbm_registry_add(reg, "Bar", "pkg.module.Bar", "Method"); + + ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Foo")); + ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Bar")); + ASSERT_FALSE(cbm_registry_exists(reg, "pkg.module.Missing")); + ASSERT_FALSE(cbm_registry_exists(reg, "")); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_confidence_import_map) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); + + const char *keys[] = {"other"}; + const char *vals[] = {"proj.other"}; + cbm_resolution_t r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1); + ASSERT_STR_EQ(r.qualified_name, "proj.other.Foo"); + ASSERT(r.confidence > 0.90 && r.confidence <= 1.0); + ASSERT_STR_EQ(r.strategy, "import_map"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_confidence_import_map_suffix) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Foo", "proj.other.sub.Foo", "Function"); + + const char *keys[] = {"other"}; + const char *vals[] = {"proj.other"}; + cbm_resolution_t r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1); + ASSERT_STR_EQ(r.qualified_name, "proj.other.sub.Foo"); + ASSERT(r.confidence > 0.80 && r.confidence <= 0.90); + ASSERT_STR_EQ(r.strategy, "import_map_suffix"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_confidence_same_module) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo"); + ASSERT(r.confidence > 0.85 && r.confidence <= 0.95); + ASSERT_STR_EQ(r.strategy, "same_module"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_confidence_unique_name) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar"); + ASSERT(r.confidence > 0.70 && r.confidence <= 0.80); + ASSERT_STR_EQ(r.strategy, "unique_name"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_confidence_suffix_match) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function"); + cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.svcA.caller", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "proj.svcA.Process"); + ASSERT(r.confidence > 0.50 && r.confidence <= 0.60); + ASSERT_STR_EQ(r.strategy, "suffix_match"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(pipeline_python_super_init_external_lsp_suppresses_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = + cbm_gbuf_upsert_node(gb, "Function", "caller", "proj.app.caller", "app.py", 1, 10, + "{}"); + int64_t suffix_target_id = cbm_gbuf_upsert_node( + gb, "Method", "__init__", "proj.fastapi.routing.APIRoute.__init__", "routing.py", 1, + 10, "{}"); + int64_t second_suffix_target_id = cbm_gbuf_upsert_node( + gb, "Method", "__init__", "proj.other.Route.__init__", "other.py", 1, 10, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(suffix_target_id, 0); + ASSERT_GT(second_suffix_target_id, 0); + cbm_registry_add(reg, "__init__", "proj.fastapi.routing.APIRoute.__init__", "Method"); + cbm_registry_add(reg, "__init__", "proj.other.Route.__init__", "Method"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "super().__init__", + .enclosing_func_qn = "proj.app.caller", + .start_line = 2}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMResolvedCall resolved = {.caller_qn = "proj.app.caller", + .callee_qn = "starlette.routing.Route.__init__", + .strategy = "lsp_type_dispatch", + .confidence = 0.95f}; + cbm_resolvedcall_push(&result.resolved_calls, &result.arena, resolved); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = {{.path = "/tmp/proj/app.py", + .rel_path = "app.py", + .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 0); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_python_super_init_without_lsp_suppresses_weak_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = cbm_gbuf_upsert_node(gb, "Method", "__init__", "proj.app.Child.__init__", + "app.py", 1, 10, "{}"); + int64_t unrelated_target_id = cbm_gbuf_upsert_node( + gb, "Method", "__init__", "proj.other.Unrelated.__init__", "other.py", 1, 10, "{}"); + int64_t second_unrelated_target_id = cbm_gbuf_upsert_node( + gb, "Method", "__init__", "proj.third.AlsoUnrelated.__init__", "third.py", 1, 10, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(unrelated_target_id, 0); + ASSERT_GT(second_unrelated_target_id, 0); + cbm_registry_add(reg, "__init__", "proj.other.Unrelated.__init__", "Method"); + cbm_registry_add(reg, "__init__", "proj.third.AlsoUnrelated.__init__", "Method"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "super().__init__", + .enclosing_func_qn = "proj.app.Child.__init__", + .start_line = 2}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = { + {.path = "/tmp/proj/app.py", .rel_path = "app.py", .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), 0); + ASSERT_EQ(edge_count, 0); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_external_lsp_target_suppresses_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = + cbm_gbuf_upsert_node(gb, "Function", "caller", "proj.app.caller", "app.py", 1, 10, + "{}"); + int64_t suffix_target_id = cbm_gbuf_upsert_node( + gb, "Method", "get", "proj.fastapi.routing.APIRouter.get", "routing.py", 1, 10, "{}"); + int64_t second_suffix_target_id = cbm_gbuf_upsert_node( + gb, "Method", "get", "proj.other.Mapping.get", "other.py", 1, 10, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(suffix_target_id, 0); + ASSERT_GT(second_suffix_target_id, 0); + cbm_registry_add(reg, "get", "proj.fastapi.routing.APIRouter.get", "Method"); + cbm_registry_add(reg, "get", "proj.other.Mapping.get", "Method"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "scope.get", + .enclosing_func_qn = "proj.app.caller", + .start_line = 2}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMResolvedCall resolved = {.caller_qn = "proj.app.caller", + .callee_qn = "external.collections.Mapping.get", + .strategy = "lsp_external_method", + .confidence = 0.95f}; + cbm_resolvedcall_push(&result.resolved_calls, &result.arena, resolved); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = {{.path = "/tmp/proj/app.py", + .rel_path = "app.py", + .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 0); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_internal_lsp_declaration_keeps_canonical_registry_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = + cbm_gbuf_upsert_node(gb, "Function", "run", "proj.main.run", "main.c", 1, 10, "{}"); + int64_t target_id = + cbm_gbuf_upsert_node(gb, "Function", "add", "proj.util.add", "util.c", 1, 10, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(target_id, 0); + cbm_registry_add(reg, "add", "proj.util.add", "Function"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "add", .enclosing_func_qn = "proj.main.run", .start_line = 2}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMResolvedCall resolved = {.caller_qn = "proj.main.run", + .callee_qn = "proj.main.add", + .strategy = "lsp_direct", + .confidence = 0.95f}; + cbm_resolvedcall_push(&result.resolved_calls, &result.arena, resolved); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = { + {.path = "/tmp/proj/main.c", .rel_path = "main.c", .language = CBM_LANG_C}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), 0); + ASSERT_EQ(edge_count, 1); + ASSERT_EQ(edges[0]->target_id, target_id); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_python_file_self_call_suppresses_weak_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = cbm_gbuf_upsert_node(gb, "File", "routing.py", + "proj.fastapi.routing.py.__file__", + "fastapi/routing.py", 1, 1, "{}"); + int64_t first_target_id = cbm_gbuf_upsert_node( + gb, "Method", "add_api_route", "proj.fastapi.routing.APIRouter.add_api_route", + "fastapi/routing.py", 10, 20, "{}"); + int64_t second_target_id = + cbm_gbuf_upsert_node(gb, "Method", "add_api_route", + "proj.fastapi.applications.FastAPI.add_api_route", + "fastapi/applications.py", 30, 40, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(first_target_id, 0); + ASSERT_GT(second_target_id, 0); + cbm_registry_add(reg, "add_api_route", "proj.fastapi.routing.APIRouter.add_api_route", + "Method"); + cbm_registry_add(reg, "add_api_route", "proj.fastapi.applications.FastAPI.add_api_route", + "Method"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "self.add_api_route", .start_line = 12}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = {{.path = "/tmp/proj/fastapi/routing.py", + .rel_path = "fastapi/routing.py", + .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 0); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_python_file_dotted_call_suppresses_weak_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = cbm_gbuf_upsert_node(gb, "File", "routing.py", + "proj.fastapi.routing.py.__file__", + "fastapi/routing.py", 1, 1, "{}"); + int64_t first_target_id = cbm_gbuf_upsert_node( + gb, "Method", "get", "proj.fastapi.routing.APIRouter.get", "fastapi/routing.py", 10, + 20, "{}"); + int64_t second_target_id = cbm_gbuf_upsert_node( + gb, "Method", "get", "proj.datastructures.Headers.get", "fastapi/datastructures.py", + 30, 40, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(first_target_id, 0); + ASSERT_GT(second_target_id, 0); + cbm_registry_add(reg, "get", "proj.fastapi.routing.APIRouter.get", "Method"); + cbm_registry_add(reg, "get", "proj.datastructures.Headers.get", "Method"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "request.headers.get", .start_line = 207}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = {{.path = "/tmp/proj/fastapi/routing.py", + .rel_path = "fastapi/routing.py", + .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 0); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_python_file_dotted_call_keeps_import_reachable_suffix_fallback) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + cbm_registry_t *reg = cbm_registry_new(); + ASSERT_NOT_NULL(gb); + ASSERT_NOT_NULL(reg); + + int64_t source_id = cbm_gbuf_upsert_node(gb, "File", "app.py", "proj.app.py.__file__", + "app.py", 1, 1, "{}"); + int64_t target_id = cbm_gbuf_upsert_node(gb, "Method", "get", "proj.client.API.get", + "client.py", 10, 20, "{}"); + int64_t other_target_id = cbm_gbuf_upsert_node(gb, "Method", "get", "proj.other.API.get", + "other.py", 10, 20, "{}"); + ASSERT_GT(source_id, 0); + ASSERT_GT(target_id, 0); + ASSERT_GT(other_target_id, 0); + cbm_registry_add(reg, "get", "proj.client.API.get", "Method"); + cbm_registry_add(reg, "get", "proj.other.API.get", "Method"); + cbm_gbuf_insert_edge(gb, source_id, target_id, "IMPORTS", "{\"local_name\":\"client\"}"); + + CBMFileResult result; + memset(&result, 0, sizeof(result)); + cbm_arena_init(&result.arena); + CBMCall call = {.callee_name = "client.get", .start_line = 3}; + cbm_calls_push(&result.calls, &result.arena, call); + CBMFileResult *result_cache[1] = {&result}; + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {.project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .registry = reg, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .result_cache = result_cache}; + cbm_file_info_t files[1] = {{.path = "/tmp/proj/app.py", + .rel_path = "app.py", + .language = CBM_LANG_PYTHON}}; + + ASSERT_EQ(cbm_pipeline_pass_calls(&ctx, files, 1), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_id, "CALLS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 1); + ASSERT_EQ(edges[0]->target_id, target_id); + + cbm_arena_destroy(&result.arena); + cbm_registry_free(reg); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(registry_fuzzy_confidence_single) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function"); + + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "unknownPkg.Handler", "proj.caller", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT(fr.result.confidence > 0.35 && fr.result.confidence <= 0.45); + ASSERT_STR_EQ(fr.result.strategy, "fuzzy"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_confidence_distance) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function"); + cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function"); + + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "unknownPkg.Process", "proj.svcA.other", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT(fr.result.confidence > 0.25 && fr.result.confidence <= 0.35); + ASSERT_STR_EQ(fr.result.strategy, "fuzzy"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_negative_import_rejects) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Process", "proj.billing.Process", "Function"); + cbm_registry_add(reg, "Process", "proj.handler.Process", "Function"); + + /* Import only handler's module → should prefer handler */ + const char *keys[] = {"handler"}; + const char *vals[] = {"proj.handler"}; + cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.caller", keys, vals, 1); + ASSERT_STR_EQ(r.qualified_name, "proj.handler.Process"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_import_penalty) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function"); + + /* Has imports but billing not imported → confidence halved */ + const char *keys[] = {"other"}; + const char *vals[] = {"proj.other"}; + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "unknown.Handler", "proj.caller", keys, vals, 1); + ASSERT_TRUE(fr.ok); + /* 0.40 * 0.5 = 0.20 */ + ASSERT(fr.result.confidence > 0.15 && fr.result.confidence <= 0.25); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_fuzzy_no_import_map_passthrough) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function"); + + /* NULL import map → no penalty, full fuzzy confidence */ + cbm_fuzzy_result_t fr = + cbm_registry_fuzzy_resolve(reg, "unknown.Handler", "proj.caller", NULL, NULL, 0); + ASSERT_TRUE(fr.ok); + ASSERT(fr.result.confidence > 0.35 && fr.result.confidence <= 0.45); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_find_by_name) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); + cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); + cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); + cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method"); + + /* FindByName returns all entries for "Foo" */ + const char **foos = NULL; + int foos_count = 0; + cbm_registry_find_by_name(reg, "Foo", &foos, &foos_count); + ASSERT_EQ(foos_count, 2); + + /* FindByName for unique "Bar" */ + const char **bars = NULL; + int bars_count = 0; + cbm_registry_find_by_name(reg, "Bar", &bars, &bars_count); + ASSERT_EQ(bars_count, 1); + ASSERT_STR_EQ(bars[0], "proj.pkg.Bar"); + + /* FindByName for "transform" */ + const char **transforms = NULL; + int trans_count = 0; + cbm_registry_find_by_name(reg, "transform", &transforms, &trans_count); + ASSERT_EQ(trans_count, 1); + ASSERT_STR_EQ(transforms[0], "proj.utils.DataProcessor.transform"); + + /* label_of */ + ASSERT_STR_EQ(cbm_registry_label_of(reg, "proj.utils.DataProcessor.transform"), "Method"); + ASSERT_STR_EQ(cbm_registry_label_of(reg, "proj.pkg.Foo"), "Function"); + + /* Total size */ + ASSERT_EQ(cbm_registry_size(reg), 4); + + /* Resolve same-module */ + cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo"); + + /* Resolve via import map */ + const char *keys[] = {"other"}; + const char *vals[] = {"proj.other"}; + r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1); + ASSERT_STR_EQ(r.qualified_name, "proj.other.Foo"); + + /* Resolve unique name */ + r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar"); + + cbm_registry_free(reg); + PASS(); +} + +TEST(registry_confidence_band) { + ASSERT_STR_EQ(cbm_confidence_band(0.95), "high"); + ASSERT_STR_EQ(cbm_confidence_band(0.70), "high"); + ASSERT_STR_EQ(cbm_confidence_band(0.55), "medium"); + ASSERT_STR_EQ(cbm_confidence_band(0.45), "medium"); + ASSERT_STR_EQ(cbm_confidence_band(0.40), "speculative"); + ASSERT_STR_EQ(cbm_confidence_band(0.25), "speculative"); + ASSERT_STR_EQ(cbm_confidence_band(0.20), ""); + ASSERT_STR_EQ(cbm_confidence_band(0.0), ""); + PASS(); +} + +TEST(infra_qn_helper) { + /* Port of TestInfraQN */ + + /* Regular infra file → __infra__ suffix */ + char *qn = cbm_infra_qn("myproject", "docker-images/service/Dockerfile", "dockerfile", NULL); + ASSERT_NOT_NULL(qn); + ASSERT(strstr(qn, ".__infra__") != NULL); + free(qn); + + /* Compose service → ::service_name suffix */ + qn = cbm_infra_qn("myproject", "docker-compose.yml", "compose-service", "web"); + ASSERT_NOT_NULL(qn); + ASSERT(strstr(qn, "::web") != NULL); + free(qn); + + PASS(); +} + +/* ── Infrascan integration tests ────────────────────────────────── */ + +TEST(infra_pipeline_integration) { + /* Port of TestPassInfraFilesIntegration (Dockerfile + .env parts). + * Tests parse functions on source text (pipeline infrascan pass not + * wired yet — compose YAML also blocked on YAML parser). */ + + /* Parse Dockerfile */ + cbm_dockerfile_result_t dr; + ASSERT_EQ(cbm_parse_dockerfile_source("FROM alpine:3.19\nEXPOSE 8080\n", &dr), 0); + ASSERT_STR_EQ(dr.base_image, "alpine:3.19"); + ASSERT_GTE(dr.port_count, 1); + + /* Parse .env */ + cbm_dotenv_result_t er; + ASSERT_EQ(cbm_parse_dotenv_source("APP_PORT=8080\nDEBUG=true\n", &er), 0); + ASSERT_GTE(er.env_count, 1); + /* APP_PORT should be present */ + bool found_port = false; + for (int i = 0; i < er.env_count; i++) { + if (strcmp(er.env_vars[i].key, "APP_PORT") == 0 && + strcmp(er.env_vars[i].value, "8080") == 0) + found_port = true; + } + ASSERT_TRUE(found_port); + + PASS(); +} + +TEST(infra_pipeline_idempotent) { + /* Port of TestPassInfraFilesIdempotent: + * Parsing same source twice should produce identical results. */ + const char *src = "FROM alpine:3.19\nEXPOSE 8080\nENV PORT=8080\n"; + cbm_dockerfile_result_t r1, r2; + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r1), 0); + ASSERT_EQ(cbm_parse_dockerfile_source(src, &r2), 0); + + ASSERT_STR_EQ(r1.base_image, r2.base_image); + ASSERT_EQ(r1.port_count, r2.port_count); + ASSERT_EQ(r1.env_count, r2.env_count); + + PASS(); +} + +/* (K8s extraction tests already defined above from origin/main) */ + +/* ── Envscan tests (port of envscan_test.go) ───────────────────── */ + +/* Helper: write a file inside a temp dir */ +static void write_temp_file(const char *dir, const char *name, const char *content) { + char path[512]; + /* Create subdirectories if needed */ + snprintf(path, sizeof(path), "%s/%s", dir, name); + char *slash = strrchr(path, '/'); + if (slash) { + char parent[512]; + size_t plen = slash - path; + memcpy(parent, path, plen); + parent[plen] = '\0'; + /* mkdir -p (simple version, one level) */ + cbm_mkdir(parent); + } + FILE *f = fopen(path, "w"); + if (f) { + fputs(content, f); + fclose(f); + } +} + +/* Helper: find binding by key in results */ +static const cbm_env_binding_t *find_binding_by_key(const cbm_env_binding_t *bindings, int count, + const char *key) { + for (int i = 0; i < count; i++) { + if (strcmp(bindings[i].key, key) == 0) + return &bindings[i]; + } + return NULL; +} + +/* Helper: find binding by value in results */ +static int has_binding_value(const cbm_env_binding_t *bindings, int count, const char *value) { + for (int i = 0; i < count; i++) { + if (strcmp(bindings[i].value, value) == 0) + return 1; + } + return 0; +} + +enum { + ENVSCAN_WIDE_DIRECTORY_COUNT = CBM_SZ_256 + 17, + ENVSCAN_OLD_FILE_LIMIT_BYTES = CBM_SZ_1K * CBM_SZ_1K, +}; + +static int envscan_write_large_file(const char *path, const char *first_line, size_t total_bytes) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) { + return -1; + } + size_t written = strlen(first_line); + if (written > total_bytes || fwrite(first_line, 1, written, file) != written) { + (void)fclose(file); + return -1; + } + static const char padding[] = "# padding keeps this a valid ignored shell comment\n"; + while (written < total_bytes) { + size_t remaining = total_bytes - written; + size_t chunk = remaining < sizeof(padding) - 1U ? remaining : sizeof(padding) - 1U; + if (fwrite(padding, 1, chunk, file) != chunk) { + (void)fclose(file); + return -1; + } + written += chunk; + } + return fclose(file); +} + +TEST(envscan_walks_more_than_256_pending_directories) { + char tmpdir[CBM_SZ_256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_wide_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + for (int i = 0; i < ENVSCAN_WIDE_DIRECTORY_COUNT; i++) { + char dir_path[CBM_SZ_512]; + char file_path[CBM_SZ_512]; + int dir_len = snprintf(dir_path, sizeof(dir_path), "%s/d_%03d", tmpdir, i); + int file_len = snprintf(file_path, sizeof(file_path), "%s/config.sh", dir_path); + ASSERT_TRUE(dir_len > 0 && (size_t)dir_len < sizeof(dir_path)); + ASSERT_TRUE(file_len > 0 && (size_t)file_len < sizeof(file_path)); + ASSERT_EQ(cbm_mkdir(dir_path), 0); + ASSERT_EQ(th_write_file(file_path, "export WIDE_URL=https://wide.example.test/v1\n"), 0); + } + + cbm_env_binding_t *bindings = calloc(ENVSCAN_WIDE_DIRECTORY_COUNT, sizeof(*bindings)); + ASSERT_NOT_NULL(bindings); + int count = cbm_scan_project_env_urls(tmpdir, bindings, ENVSCAN_WIDE_DIRECTORY_COUNT); + ASSERT_EQ(count, ENVSCAN_WIDE_DIRECTORY_COUNT); + + free(bindings); + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_accepts_root_path_longer_than_512_bytes) { + char cleanup_root[CBM_SZ_256]; + char scan_root[CBM_SZ_2K]; + snprintf(cleanup_root, sizeof(cleanup_root), "/tmp/cbm_envscan_longroot_XXXXXX"); + if (!cbm_mkdtemp(cleanup_root)) + FAIL("tmpdir"); + snprintf(scan_root, sizeof(scan_root), "%s", cleanup_root); + + int component = 0; + while (strlen(scan_root) <= CBM_SZ_512) { + size_t used = strlen(scan_root); + int appended = snprintf(scan_root + used, sizeof(scan_root) - used, + "/component_%03d_abcdefghijkl", component++); + ASSERT_TRUE(appended > 0 && (size_t)appended < sizeof(scan_root) - used); + } + /* Create the complete deep path once through the same UTF-8/extended-path + * helper used by production. Re-running mkdir-p for every prefix would add + * avoidable O(D * P) test setup work for depth D and final path length P. */ + ASSERT_EQ(th_mkdir_p(scan_root), 0); + char file_path[CBM_SZ_2K]; + int file_len = snprintf(file_path, sizeof(file_path), "%s/config.sh", scan_root); + ASSERT_TRUE(file_len > 0 && (size_t)file_len < sizeof(file_path)); + ASSERT_EQ(th_write_file(file_path, "export LONG_ROOT_URL=https://long.example.test/v1\n"), 0); + + cbm_env_binding_t bindings[2] = {0}; + int count = cbm_scan_project_env_urls(scan_root, bindings, 2); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "LONG_ROOT_URL")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "LONG_ROOT_URL")->file_path, "config.sh"); + + th_rmtree(cleanup_root); + PASS(); +} + +TEST(envscan_uses_shared_file_size_policy) { + char tmpdir[CBM_SZ_256]; + char file_path[CBM_SZ_512]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_large_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + snprintf(file_path, sizeof(file_path), "%s/config.sh", tmpdir); + ASSERT_EQ(envscan_write_large_file(file_path, + "export LARGE_FILE_URL=https://large.example.test/v1\n", + (size_t)ENVSCAN_OLD_FILE_LIMIT_BYTES + CBM_SZ_1K), + 0); + + cbm_env_binding_t bindings[2] = {0}; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 2); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "LARGE_FILE_URL")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_parses_one_complete_line_across_old_buffer_boundary) { + char tmpdir[CBM_SZ_256]; + char file_path[CBM_SZ_512]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_longline_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + snprintf(file_path, sizeof(file_path), "%s/config.sh", tmpdir); + + const char assignment[] = "BOUNDARY_URL=https://boundary.example.test/v1\n"; + size_t prefix_len = CBM_SZ_2K - strlen("BOUNDARY_URL=https"); + char *line = malloc(prefix_len + sizeof(assignment)); + ASSERT_NOT_NULL(line); + memset(line, ' ', prefix_len); + memcpy(line + prefix_len, assignment, sizeof(assignment)); + ASSERT_EQ(th_write_file(file_path, line), 0); + free(line); + + cbm_env_binding_t bindings[2] = {0}; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 2); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "BOUNDARY_URL")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "BOUNDARY_URL")->value, + "https://boundary.example.test/v1"); + + th_rmtree(tmpdir); + PASS(); +} + +enum { + ENVSCAN_CONCURRENT_SCANNER_COUNT = 4, + ENVSCAN_CONCURRENT_SCAN_REPETITIONS = 16, +}; + +typedef struct { + const char *root; + int successful_scans; +} envscan_thread_context_t; + +static void *envscan_concurrent_scan_worker(void *opaque) { + envscan_thread_context_t *context = opaque; + for (int i = 0; i < ENVSCAN_CONCURRENT_SCAN_REPETITIONS; i++) { + cbm_env_binding_t binding = {0}; + int count = cbm_scan_project_env_urls(context->root, &binding, 1); + if (count == 1 && strcmp(binding.key, "CONCURRENT_URL") == 0 && + strcmp(binding.value, "https://concurrent.example.test/v1") == 0) { + context->successful_scans++; + } + } + return NULL; +} + +TEST(envscan_concurrent_first_use_and_cleanup_reinitialize) { + char tmpdir[CBM_SZ_256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_threads_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + write_temp_file(tmpdir, "config.sh", + "export CONCURRENT_URL=https://concurrent.example.test/v1\n"); + + /* Force the workers through the lazy first-use path together. The TSan + * target proves publication of the shared compiled regexes is ordered. */ + cbm_envscan_free_patterns(); + cbm_thread_t threads[ENVSCAN_CONCURRENT_SCANNER_COUNT]; + envscan_thread_context_t contexts[ENVSCAN_CONCURRENT_SCANNER_COUNT] = {0}; + int created = 0; + for (; created < ENVSCAN_CONCURRENT_SCANNER_COUNT; created++) { + contexts[created].root = tmpdir; + if (cbm_thread_create(&threads[created], 0, envscan_concurrent_scan_worker, + &contexts[created]) != 0) { + break; + } + } + for (int i = 0; i < created; i++) { + ASSERT_EQ(cbm_thread_join(&threads[i]), 0); + ASSERT_EQ(contexts[i].successful_scans, ENVSCAN_CONCURRENT_SCAN_REPETITIONS); + } + ASSERT_EQ(created, ENVSCAN_CONCURRENT_SCANNER_COUNT); + + cbm_envscan_free_patterns(); + cbm_env_binding_t binding = {0}; + ASSERT_EQ(cbm_scan_project_env_urls(tmpdir, &binding, 1), 1); + ASSERT_STR_EQ(binding.key, "CONCURRENT_URL"); + ASSERT_STR_EQ(binding.value, "https://concurrent.example.test/v1"); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_reports_unrepresentable_key_and_value_without_truncating) { + char tmpdir[CBM_SZ_256]; + char file_path[CBM_SZ_512]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_fields_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + snprintf(file_path, sizeof(file_path), "%s/config.sh", tmpdir); + + const char normal_url[] = "https://field.example.test/v1"; + const char value_key[] = "VALUE_URL="; + const char value_prefix[] = "https://field.example.test/"; + const size_t key_length = sizeof(((cbm_env_binding_t *)0)->key); + const size_t value_length = sizeof(((cbm_env_binding_t *)0)->value); + FILE *file = cbm_fopen(file_path, "wb"); + ASSERT_NOT_NULL(file); + for (size_t i = 0; i < key_length; i++) { + ASSERT_NEQ(fputc('K', file), EOF); + } + ASSERT_NEQ(fputc('=', file), EOF); + ASSERT_EQ(fwrite(normal_url, 1, strlen(normal_url), file), strlen(normal_url)); + ASSERT_NEQ(fputc('\n', file), EOF); + ASSERT_EQ(fwrite(value_key, 1, strlen(value_key), file), strlen(value_key)); + ASSERT_EQ(fwrite(value_prefix, 1, strlen(value_prefix), file), strlen(value_prefix)); + for (size_t i = strlen(value_prefix); i < value_length; i++) { + ASSERT_NEQ(fputc('v', file), EOF); + } + ASSERT_NEQ(fputc('\n', file), EOF); + ASSERT_EQ(fclose(file), 0); + + cbm_env_binding_t bindings[2] = {0}; + pipeline_capture_logs_start(); + int count = cbm_scan_project_env_urls(tmpdir, bindings, 2); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(count, 0); + ASSERT_NOT_NULL(strstr(logs, "reason=binding_unrepresentable")); + ASSERT_NOT_NULL(strstr(logs, "constraint=key_capacity")); + ASSERT_NOT_NULL(strstr(logs, "constraint=value_capacity")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_preserves_caller_output_capacity) { + char tmpdir[CBM_SZ_256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_capacity_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + write_temp_file(tmpdir, "config.sh", + "export FIRST_URL=https://first.example.test/v1\n" + "export SECOND_URL=https://second.example.test/v1\n"); + + cbm_env_binding_t bindings[2] = {0}; + cbm_str_copy(bindings[1].key, sizeof(bindings[1].key), "UNTOUCHED"); + int count = cbm_scan_project_env_urls(tmpdir, bindings, 1); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(bindings[1].key, "UNTOUCHED"); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_dockerfile_env_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_dock_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "Dockerfile", + "FROM python:3.9-slim\n" + "ENV ORDER_URL=https://api.example.com/api/orders\n" + "ENV DB_HOST=localhost\n" + "ARG WEBHOOK_URL=https://hooks.example.com/webhook\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "ORDER_URL")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "ORDER_URL")->value, + "https://api.example.com/api/orders"); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "WEBHOOK_URL")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "WEBHOOK_URL")->value, + "https://hooks.example.com/webhook"); + /* DB_HOST=localhost is NOT a URL → should be absent */ + ASSERT_TRUE(find_binding_by_key(bindings, count, "DB_HOST") == NULL); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_shell_env_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sh_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "setup.sh", + "#!/bin/bash\n" + "export DB_URL=\"https://db.example.com/api/sync\"\n" + "APP_NAME=\"my-service\"\n" + "CALLBACK_URL=https://hooks.example.com/notify\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "DB_URL")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "DB_URL")->value, + "https://db.example.com/api/sync"); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CALLBACK_URL")); + /* APP_NAME is NOT a URL → absent */ + ASSERT_TRUE(find_binding_by_key(bindings, count, "APP_NAME") == NULL); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_env_file_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_env_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, ".env", + "\nAPI_URL=https://api.example.com/v1\n" + "DEBUG=true\n" + "SERVICE_URL=https://service.example.com/api\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "API_URL")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "API_URL")->value, + "https://api.example.com/v1"); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "SERVICE_URL")); + /* DEBUG=true is NOT a URL */ + ASSERT_TRUE(find_binding_by_key(bindings, count, "DEBUG") == NULL); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_toml_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_toml_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "config.toml", + "[service]\n" + "base_url = \"https://api.example.com\"\n" + "name = \"my-service\"\n" + "callback_url = \"https://hooks.example.com/notify\"\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "base_url")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "base_url")->value, + "https://api.example.com"); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "callback_url")); + /* name="my-service" is NOT a URL */ + ASSERT_TRUE(find_binding_by_key(bindings, count, "name") == NULL); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_yaml_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_yaml_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "config.yaml", + "service:\n" + " service_url: \"https://api.internal.com/api/process\"\n" + " timeout: 30\n" + " callback_url: \"https://hooks.internal.com/callback\"\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "service_url")); + ASSERT_STR_EQ(find_binding_by_key(bindings, count, "service_url")->value, + "https://api.internal.com/api/process"); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "callback_url")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_terraform_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_tf_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "variables.tf", + "variable \"webhook_url\" {\n" + " description = \"Webhook endpoint\"\n" + " default = \"https://api.example.com/webhook\"\n" + "}\n\n" + "variable \"region\" {\n" + " default = \"us-east-1\"\n" + "}\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_GTE(count, 1); + ASSERT_TRUE(has_binding_value(bindings, count, "https://api.example.com/webhook")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_properties_urls) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_prop_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "app.properties", + "api.url=https://api.example.com/health\n" + "app.name=myapp\n" + "service.endpoint=https://service.example.com/api\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_TRUE(has_binding_value(bindings, count, "https://api.example.com/health")); + ASSERT_TRUE(has_binding_value(bindings, count, "https://service.example.com/api")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_secret_key_exclusion) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_skey_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "Dockerfile", + "FROM node:18\n" + "ENV SECRET_TOKEN=https://api.example.com/api\n" + "ENV API_KEY=https://api.example.com/v1\n" + "ENV PASSWORD=https://auth.example.com/login\n" + "ENV NORMAL_URL=https://api.example.com/orders\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + /* Secret keys should be excluded */ + ASSERT_TRUE(find_binding_by_key(bindings, count, "SECRET_TOKEN") == NULL); + ASSERT_TRUE(find_binding_by_key(bindings, count, "API_KEY") == NULL); + ASSERT_TRUE(find_binding_by_key(bindings, count, "PASSWORD") == NULL); + /* Normal key should be present */ + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "NORMAL_URL")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_secret_value_exclusion) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sval_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file( + tmpdir, "deploy.sh", + "#!/bin/bash\n" + "export GH_URL=\"https://ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE@github.com/repo\"\n" + "export NORMAL_ENDPOINT=\"https://api.example.com/orders\"\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + /* ghp_ token URL should be excluded */ + ASSERT_TRUE(find_binding_by_key(bindings, count, "GH_URL") == NULL); + /* Normal URL should be present */ + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "NORMAL_ENDPOINT")); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_secret_file_exclusion) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sfile_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + /* Secret file should be skipped */ + write_temp_file(tmpdir, "credentials.sh", + "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + /* Normal file should be scanned */ + write_temp_file(tmpdir, "setup.sh", + "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + /* Should find binding from setup.sh but not credentials.sh */ + int from_credentials = 0; + int from_setup = 0; + for (int i = 0; i < count; i++) { + if (strcmp(bindings[i].file_path, "credentials.sh") == 0) + from_credentials = 1; + if (strcmp(bindings[i].file_path, "setup.sh") == 0) + from_setup = 1; + } + ASSERT_EQ(from_credentials, 0); + ASSERT_EQ(from_setup, 1); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_skips_ignored_dirs) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_ign_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + /* File inside .git should be skipped */ + char gitdir[512]; + snprintf(gitdir, sizeof(gitdir), "%s/.git", tmpdir); + cbm_mkdir(gitdir); + write_temp_file(tmpdir, ".git/config.sh", + "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + + /* File inside node_modules should be skipped */ + char nmdir[512]; + snprintf(nmdir, sizeof(nmdir), "%s/node_modules", tmpdir); + cbm_mkdir(nmdir); + char nmpkg[512]; + snprintf(nmpkg, sizeof(nmpkg), "%s/node_modules/pkg", tmpdir); + cbm_mkdir(nmpkg); + write_temp_file(tmpdir, "node_modules/pkg/config.sh", + "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + + /* File at root level should be scanned */ + write_temp_file(tmpdir, "deploy.sh", + "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + int from_git = 0, from_nm = 0, from_root = 0; + for (int i = 0; i < count; i++) { + if (strncmp(bindings[i].file_path, ".git/", 5) == 0) + from_git = 1; + if (strncmp(bindings[i].file_path, "node_modules/", 13) == 0) + from_nm = 1; + if (strcmp(bindings[i].file_path, "deploy.sh") == 0) + from_root = 1; + } + ASSERT_EQ(from_git, 0); + ASSERT_EQ(from_nm, 0); + ASSERT_EQ(from_root, 1); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(envscan_does_not_follow_links_outside_root) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX symlink containment test; Windows reparse-point behavior has a compile gate"); +#else + char root[256]; + char outside[256]; + snprintf(root, sizeof(root), "/tmp/cbm_envscan_root_XXXXXX"); + snprintf(outside, sizeof(outside), "/tmp/cbm_envscan_outside_XXXXXX"); + if (!cbm_mkdtemp(root) || !cbm_mkdtemp(outside)) { + th_rmtree(root); + th_rmtree(outside); + FAIL("tmpdir"); + } + + write_temp_file(root, "control.sh", + "export CONTROL_URL=https://control.example.com/v1\n"); + write_temp_file(outside, "outside.sh", + "export OUTSIDE_URL=https://outside.example.com/v1\n"); + + char linked_dir[512]; + char outside_file[512]; + char linked_file[512]; + snprintf(linked_dir, sizeof(linked_dir), "%s/linked", root); + snprintf(outside_file, sizeof(outside_file), "%s/outside.sh", outside); + snprintf(linked_file, sizeof(linked_file), "%s/linked.sh", root); + ASSERT_EQ(symlink(outside, linked_dir), 0); + ASSERT_EQ(symlink(outside_file, linked_file), 0); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(root, bindings, 32); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL")); + ASSERT_NULL(find_binding_by_key(bindings, count, "OUTSIDE_URL")); + + ASSERT_EQ(unlink(linked_file), 0); + ASSERT_EQ(unlink(linked_dir), 0); + th_rmtree(root); + th_rmtree(outside); + PASS(); +#endif +} + +TEST(envscan_non_url_values_skipped) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_nurl_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + write_temp_file(tmpdir, "Dockerfile", + "FROM python:3.9\n" + "ENV APP_NAME=my-service\n" + "ENV PORT=8080\n" + "ENV DEBUG=true\n" + "ENV LOG_LEVEL=info\n"); + write_temp_file(tmpdir, "config.sh", + "#!/bin/bash\n" + "export REGION=\"us-east-1\"\n" + "export COUNT=42\n"); + + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + + ASSERT_EQ(count, 0); + + th_rmtree(tmpdir); + PASS(); +} + +/* ── Discovery-exclusion plumbing in auxiliary repo walks (#792) ── */ + +/* Boundary semantics of the shared exclusion predicate: anchored at the + * repo root, matches the excluded dir itself and its subtree, but never + * sibling names sharing a prefix. Regression guard for issue #792. */ +TEST(pipeline_relpath_excluded_boundary) { + char *excluded[] = {(char *)"vendor_big", (char *)"packages/big"}; + + /* Exact match and subtree paths are excluded. */ + ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("vendor_big", excluded, 2)); + ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("vendor_big/lib/package.json", excluded, 2)); + ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("packages/big", excluded, 2)); + ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("packages/big/src/x.ts", excluded, 2)); + + /* Sibling names sharing the prefix are NOT excluded ('/'-boundary). */ + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_bigger", excluded, 2)); + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor", excluded, 2)); + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("packages/bigger/x.ts", excluded, 2)); + + /* Exclusions are root-anchored prefixes, not substring matches. */ + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("src/vendor_big/x.c", excluded, 2)); + + /* NULL / empty safety. */ + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded(NULL, excluded, 2)); + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("", excluded, 2)); + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", NULL, 0)); + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", excluded, 0)); + char *with_empty[] = {(char *)""}; + ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", with_empty, 1)); + PASS(); +} + +/* Helper: does the entries array contain a package with this name? */ +static int pkg_entries_has_name(const cbm_pkg_entries_t *e, const char *name) { + for (int i = 0; i < e->count; i++) { + if (e->items[i].pkg_name && strcmp(e->items[i].pkg_name, name) == 0) + return 1; + } + return 0; +} + +/* Helper: return the entry_rel registered for `name`, or NULL. */ +static const char *pkg_entries_entry_for(const cbm_pkg_entries_t *e, const char *name) { + for (int i = 0; i < e->count; i++) { + if (e->items[i].pkg_name && strcmp(e->items[i].pkg_name, name) == 0) + return e->items[i].entry_rel; + } + return NULL; +} + +/* ── SwiftPM Package.swift manifest resolution (issue #551 item 1) ── + * + * parse_package_swift is a literal pattern-extractor (mirrors + * parse_cargo_toml), not a Swift evaluator. These call cbm_pkgmap_try_parse + * directly, covering the RED categories the maintainer asked for (local + * path deps, remote identities, products not aliasing, targets, target-name + * deps, literal + computed `path:`, and comment/string false positives) + * plus fail-closed ambiguous-name cases. See pipeline_swift_cross_package_import + * above for the full end-to-end proof. */ + +TEST(pkgmap_swift_targets_registers_module) { + static const char src[] = + "// swift-tools-version:5.9\n" + "import PackageDescription\n" + "let package = Package(\n" + " name: \"Core\",\n" + " targets: [.target(name: \"Core\", dependencies: [])]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); + ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core"); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* PackageDescription executableTarget declarations create importable Swift + * modules just like regular target declarations. The manifest scanner must + * recognize the factory itself rather than keying capability to one spelling. */ +TEST(pkgmap_swift_executable_target_registers_module) { + static const char src[] = + "let package = Package(\n" + " name: \"Tooling\",\n" + " targets: [.executableTarget(name: \"Tooling\", dependencies: [])]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "Tools/Package.swift", src, (int)strlen(src), + &entries)); + ASSERT_TRUE(pkg_entries_has_name(&entries, "Tooling")); + ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Tooling"), "Tools/Sources/Tooling"); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* Manifest paths are input data, not a semantic output budget. A fixed local + * buffer used to return a plausible but truncated module path. Keep the full + * value or fail allocation; never silently redirect an IMPORTS edge. */ +TEST(pkgmap_swift_literal_path_is_not_silently_truncated) { + enum { SEGMENTS = 180 }; + char path[SEGMENTS * sizeof("nested/") + sizeof("Module")]; + size_t used = 0; + for (int i = 0; i < SEGMENTS; i++) { + memcpy(path + used, "nested/", sizeof("nested/") - 1); + used += sizeof("nested/") - 1; + } + memcpy(path + used, "Module", sizeof("Module")); + + const char *prefix = + "let package = Package(name: \"Deep\", targets: [.target(name: \"Deep\", path: \""; + const char *suffix = "\")])\n"; + size_t source_len = strlen(prefix) + strlen(path) + strlen(suffix); + char *source = malloc(source_len + 1); + ASSERT_NOT_NULL(source); + snprintf(source, source_len + 1, "%s%s%s", prefix, path, suffix); + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "Deep/Package.swift", source, (int)source_len, + &entries)); + const char *entry = pkg_entries_entry_for(&entries, "Deep"); + ASSERT_NOT_NULL(entry); + ASSERT_EQ(strlen(entry), strlen("Deep/") + strlen(path)); + ASSERT_TRUE(strlen(entry) >= strlen("/Module")); + ASSERT_STR_EQ(entry + strlen(entry) - strlen("/Module"), "/Module"); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + free(source); + PASS(); +} + +/* Products deliberately do NOT self-register a separate alias: a product + * name is not generally an importable module (SwiftPM lets it alias + * multiple targets, or none sharing its own name), so only the underlying + * target -- under its OWN name -- registers. */ +TEST(pkgmap_swift_products_do_not_register_alias) { + static const char src[] = + "let package = Package(\n" + " name: \"Core\",\n" + " products: [.library(name: \"CoreKit\", targets: [\"CoreImpl\"])],\n" + " targets: [.target(name: \"CoreImpl\", dependencies: [])]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_FALSE(pkg_entries_has_name(&entries, "CoreKit")); + ASSERT_TRUE(pkg_entries_has_name(&entries, "CoreImpl")); + ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "CoreImpl"), "Core/Sources/CoreImpl"); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* Regression: a target whose `name:` is the LAST argument, immediately + * followed by the call's own closing ')' with no trailing comma, must still + * register. swift_quoted_literal's terminator check used to compare against + * `end` with a strict '<', but every caller passes the wrapping call's own + * ')' position AS `end` -- so the literal's closing quote landing exactly + * on that boundary was wrongly rejected as "unterminated". Every other + * fixture in this file happens to follow `name:` with `dependencies:` or a + * comma, so this specific shape was previously untested and unnoticed. */ +TEST(pkgmap_swift_target_name_immediately_before_close_paren) { + static const char src[] = + "let package = Package(\n" + " name: \"Core\",\n" + " targets: [.target(name: \"Core\")]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); + ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core"); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* A literal `path:` argument overrides the Sources/ convention. */ +TEST(pkgmap_swift_target_honors_literal_path) { + static const char src[] = + "let package = Package(\n" + " name: \"Core\",\n" + " targets: [.target(name: \"Core\", path: \"Vendor/CoreLegacy\")]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); + ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Vendor/CoreLegacy"); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* A `path:` argument that IS present but not a bare literal (computed) is + * unknowable -- SwiftPM would not use the Sources/ convention here, + * so guessing it anyway would mint a location likely to be wrong. Skip the + * target entirely (fail closed), even though its `name:` is a valid + * literal. */ +TEST(pkgmap_swift_target_computed_path_fails_closed) { + static const char src[] = + "let customPath = computePath()\n" + "let package = Package(\n" + " name: \"Core\",\n" + " targets: [.target(name: \"Core\", path: customPath)]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_EQ(entries.count, 0); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* A `.target(` spelled inside a `//` line comment, a nesting-aware + * slash-star block comment, or a string literal must never be mistaken for a live + * declaration -- the bug a raw strstr scan cannot avoid. Only the one real + * target registers. */ +TEST(pkgmap_swift_target_in_comment_or_string_not_registered) { + static const char src[] = + "// .target(name: \"Decoy\")\n" + "/* outer /* nested */ still a comment: .target(name: \"NestedDecoy\") */\n" + "let manifestSnippet = \".target(name: \\\"StringDecoy\\\")\"\n" + "let package = Package(\n" + " name: \"App\",\n" + " targets: [.target(name: \"App\", dependencies: [])]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_TRUE(pkg_entries_has_name(&entries, "App")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "Decoy")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "NestedDecoy")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "StringDecoy")); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* Local path + remote url dependencies (`.package(path:)` / `.package(url:)`) + * mint NO entries of their own -- mirroring package.json/Cargo.toml, only a + * manifest's OWN products/targets self-register. A local sibling's name is + * produced by ITS OWN Package.swift when the repo-wide walk reaches it + * (see repro_issue408.c's JS-workspace analog); a remote dependency has no + * local path to point at, so nothing is minted (fail-closed). */ +TEST(pkgmap_swift_dependencies_do_not_leak_entries) { + static const char src[] = + "let package = Package(\n" + " name: \"App\",\n" + " dependencies: [\n" + " .package(path: \"../Core\"),\n" + " .package(url: \"https://github.com/example/RemoteKit.git\", from: \"1.0.0\")\n" + " ],\n" + " targets: [.target(name: \"App\", dependencies: [])]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_TRUE(pkg_entries_has_name(&entries, "App")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "Core")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "RemoteKit")); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* Only App itself (the declaring target) registers -- a bare same-package + * target-name dependency ("Core") and a cross-package product dependency + * (Utils/UtilsPkg) name OTHER modules, not this manifest's own + * products/targets, so neither mints an entry. */ +TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry) { + static const char src[] = + "let package = Package(\n" + " name: \"App\",\n" + " targets: [.target(name: \"App\", dependencies: [\n" + " \"Core\",\n" + " .product(name: \"Utils\", package: \"UtilsPkg\")\n" + " ])]\n" + ")\n"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, + (int)strlen(src), &entries); + ASSERT_TRUE(ok); + ASSERT_TRUE(pkg_entries_has_name(&entries, "App")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "Core")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "Utils")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "UtilsPkg")); + ASSERT_EQ(entries.count, 1); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* Fail-closed on any `name:` that is not a bare literal: a computed + * variable, and a literal concatenated with a dynamic suffix (which a + * naive quote-scan would wrongly accept as "App"). Neither mints an entry, + * though the manifest is still recognized and parsed. */ +TEST(pkgmap_swift_ambiguous_target_name_fails_closed) { + static const char dynamic_src[] = + "let generatedName = \"App\" + String(buildNumber)\n" + "let package = Package(\n" + " name: \"App\",\n" + " targets: [.target(name: generatedName, dependencies: [])]\n" + ")\n"; + static const char concat_src[] = + "let package = Package(\n" + " name: \"App\",\n" + " targets: [.target(name: \"App\" + suffix, dependencies: [])]\n" + ")\n"; + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", dynamic_src, + (int)strlen(dynamic_src), &entries)); + ASSERT_EQ(entries.count, 0); + cbm_pkg_entries_free(&entries); + + cbm_pkg_entries_init(&entries); + ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", concat_src, + (int)strlen(concat_src), &entries)); + ASSERT_EQ(entries.count, 0); + cbm_pkg_entries_free(&entries); + PASS(); +} + +/* The repo-wide manifest walker must also recognize "Package.swift" -- a + * second code path (is_pkgmap_manifest_basename) from the direct + * cbm_pkgmap_try_parse calls above. */ +TEST(pkgmap_swift_scan_repo_finds_nested_manifest) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_swift_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + char dir[512]; + snprintf(dir, sizeof(dir), "%s/Core", tmpdir); + cbm_mkdir(dir); + write_temp_file(tmpdir, "Core/Package.swift", + "let package = Package(\n" + " name: \"Core\",\n" + " targets: [.target(name: \"Core\", dependencies: [])]\n" + ")\n"); + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0); + ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); + cbm_pkg_entries_free(&entries); + + th_rmtree(tmpdir); + PASS(); +} + +/* The pkgmap repo walk must honor discovery exclusions (issue #792: a + * gitignored huge subtree kept the pkgmap walk busy for 15 minutes). + * Control run first (no exclusions → BOTH manifests parsed) so the + * exclusion assertion below cannot pass vacuously. */ +TEST(pkgmap_scan_repo_honors_discovery_exclusions) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_excl_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + char dir[512]; + snprintf(dir, sizeof(dir), "%s/packages", tmpdir); + cbm_mkdir(dir); + snprintf(dir, sizeof(dir), "%s/packages/app", tmpdir); + cbm_mkdir(dir); + snprintf(dir, sizeof(dir), "%s/large_ignored", tmpdir); + cbm_mkdir(dir); + snprintf(dir, sizeof(dir), "%s/large_ignored/lib", tmpdir); + cbm_mkdir(dir); + + write_temp_file(tmpdir, "packages/app/package.json", + "{\"name\":\"@org/app\",\"main\":\"index.js\"}\n"); + write_temp_file(tmpdir, "large_ignored/lib/package.json", + "{\"name\":\"@org/vendored\",\"main\":\"index.js\"}\n"); + + /* Control: NULL exclusion list — the walk reaches and parses BOTH + * manifests (proves the excluded one is reachable + parseable). */ + cbm_pkg_entries_t control; + cbm_pkg_entries_init(&control); + cbm_pkgmap_scan_repo(tmpdir, &control, NULL, 0); + ASSERT_TRUE(pkg_entries_has_name(&control, "@org/app")); + ASSERT_TRUE(pkg_entries_has_name(&control, "@org/vendored")); + cbm_pkg_entries_free(&control); + + /* With large_ignored excluded (as discovery reports for a gitignored + * subtree): the walk must not descend into it. */ + char *excluded[] = {(char *)"large_ignored"}; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + cbm_pkgmap_scan_repo(tmpdir, &entries, excluded, 1); + ASSERT_TRUE(pkg_entries_has_name(&entries, "@org/app")); + ASSERT_FALSE(pkg_entries_has_name(&entries, "@org/vendored")); + cbm_pkg_entries_free(&entries); + + th_rmtree(tmpdir); + PASS(); +} + +enum { + /* Exceeds the former 1,024-byte resolver buffer after prefix/appended path. */ + PKGMAP_LONG_TEST_FILL = 1100, +}; + +static char *pkgmap_long_test_value(const char *prefix, char fill, size_t fill_count) { + size_t prefix_len = strlen(prefix); + char *value = malloc(prefix_len + fill_count + 1); + if (!value) { + return NULL; + } + memcpy(value, prefix, prefix_len); + memset(value + prefix_len, fill, fill_count); + value[prefix_len + fill_count] = '\0'; + return value; +} + +/* Package entry resolution must preserve the complete manifest directory and + * entry value. A fixed join buffer can otherwise redirect an import to a + * plausible but different module. */ +TEST(pkgmap_package_json_entry_is_not_silently_truncated) { + char *directory = pkgmap_long_test_value("packages/", 'p', PKGMAP_LONG_TEST_FILL); + ASSERT_NOT_NULL(directory); + size_t rel_path_len = strlen(directory) + strlen("/package.json"); + char *rel_path = malloc(rel_path_len + 1); + ASSERT_NOT_NULL(rel_path); + snprintf(rel_path, rel_path_len + 1, "%s/package.json", directory); + + static const char source[] = "{\"name\":\"@org/deep\",\"main\":\"src/index.js\"}"; + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + ASSERT_TRUE( + cbm_pkgmap_try_parse("package.json", rel_path, source, (int)strlen(source), &entries)); + const char *resolved = pkg_entries_entry_for(&entries, "@org/deep"); + ASSERT_NOT_NULL(resolved); + size_t expected_len = strlen(directory) + strlen("/src/index"); + ASSERT_EQ(strlen(resolved), expected_len); + ASSERT_STR_EQ(resolved + strlen(directory), "/src/index"); + + cbm_pkg_entries_free(&entries); + free(rel_path); + free(directory); + PASS(); +} + +/* The filesystem walker must preserve each platform-supported path byte. A + * local fixed buffer otherwise makes two distinct deep entries alias before + * stat, exclusion matching, or manifest parsing sees them. */ +TEST(pkgmap_walk_path_join_is_not_silently_truncated) { + char *directory = pkgmap_long_test_value("workspace/", 'w', PKGMAP_LONG_TEST_FILL); + char *name = pkgmap_long_test_value("package-", 'n', PKGMAP_LONG_TEST_FILL); + if (!directory || !name) { + free(name); + free(directory); + FAIL("walker path fixture allocation"); + } + size_t expected_len = strlen(directory) + SKIP_ONE + strlen(name); + char *joined = cbm_pkgmap_join_path(directory, name); + bool exact = joined && strlen(joined) == expected_len && + memcmp(joined, directory, strlen(directory)) == 0 && + joined[strlen(directory)] == '/' && + strcmp(joined + strlen(directory) + SKIP_ONE, name) == 0; + + free(joined); + free(name); + free(directory); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(pkgmap_walk_reaches_manifest_beyond_legacy_depth_cap) { + enum { + /* The former walker stopped at 64 recursive frames. This finite tree + * stays well inside platform path limits while proving deeper content + * is not silently omitted. */ + PKGMAP_DEEP_TEST_LEVELS = 80, + }; + char tmpdir[CBM_SZ_256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_deep_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("deep pkgmap tmpdir"); + } + + char *directory = cbm_strdup(tmpdir); + for (int level = 0; directory && level < PKGMAP_DEEP_TEST_LEVELS; level++) { + char *next = cbm_pkgmap_join_path(directory, "d"); + free(directory); + directory = next; + } + char *manifest = directory ? cbm_pkgmap_join_path(directory, "package.json") : NULL; + bool fixture_ready = directory && manifest && cbm_mkdir_p(directory, 0700); + FILE *file = fixture_ready ? cbm_fopen(manifest, "wb") : NULL; + static const char source[] = "{\"name\":\"@org/deep\",\"main\":\"index.js\"}\n"; + if (file) { + bool wrote = + fwrite(source, SKIP_ONE, sizeof(source) - SKIP_ONE, file) == sizeof(source) - SKIP_ONE; + int close_result = fclose(file); + fixture_ready = wrote && close_result == 0; + } else { + fixture_ready = false; + } + if (!fixture_ready) { + free(manifest); + free(directory); + th_rmtree(tmpdir); + FAIL("deep pkgmap fixture"); + } + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + int parsed = cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0); + bool found = pkg_entries_has_name(&entries, "@org/deep"); + cbm_pkg_entries_free(&entries); + free(manifest); + free(directory); + th_rmtree(tmpdir); + + ASSERT_EQ(parsed, 1); + ASSERT_TRUE(found); + PASS(); +} + +/* A directory symlink back to an active ancestor must not duplicate manifests + * or keep the now-unbounded iterative walk alive. Windows uses reparse-point + * inspection for the equivalent junction/symlink rule and has separate + * compile/platform gates because creating one requires platform privileges. */ +TEST(pkgmap_walk_does_not_follow_directory_symlink_cycle) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX symlink cycle; Windows reparse behavior is compile-gated"); +#else + char tmpdir[CBM_SZ_256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_cycle_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("pkgmap cycle tmpdir"); + } + write_temp_file(tmpdir, "package.json", "{\"name\":\"@org/root\",\"main\":\"index.js\"}\n"); + + char *cycle_path = cbm_pkgmap_join_path(tmpdir, "cycle"); + bool fixture_ready = cycle_path && symlink(tmpdir, cycle_path) == 0; + if (!fixture_ready) { + free(cycle_path); + th_rmtree(tmpdir); + FAIL("pkgmap cycle symlink"); + } + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + int parsed = cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0); + bool exact = parsed == 1 && entries.count == 1 && pkg_entries_has_name(&entries, "@org/root"); + cbm_pkg_entries_free(&entries); + int unlink_result = unlink(cycle_path); + free(cycle_path); + th_rmtree(tmpdir); + + ASSERT_EQ(unlink_result, 0); + ASSERT_TRUE(exact); + PASS(); +#endif +} + +/* Every manifest parser must preserve the same exact directory identity. This + * table exercises the seven parsers that historically rebuilt entry paths in + * independent 1,024-byte buffers; one shared fixture prevents ecosystem fixes + * from drifting while keeping each parser's manifest semantics distinct. */ +TEST(pkgmap_manifest_ecosystems_preserve_long_entry_paths) { + typedef struct { + const char *basename; + const char *source; + const char *package_name; + const char *entry_suffix; + } pkgmap_manifest_case_t; + static const pkgmap_manifest_case_t cases[] = { + {"pyproject.toml", "[project]\nname = \"deep_pkg\"\n", "deep_pkg", "src/deep_pkg/__init__"}, + {"composer.json", + "{\"name\":\"vendor/package\",\"autoload\":{\"psr-4\":{\"Vendor\\\\\":\"src/\"}}}", + "Vendor\\", "src"}, + {"pubspec.yaml", "name: deep_dart\n", "deep_dart", "lib"}, + {"pom.xml", + "com.exampledemo", + "com.example.demo", "src/main/java"}, + {"build.gradle", "group = 'com.example'\n", "com.example", "src/main/java"}, + {"mix.exs", "app: :deep_app,\n", "deep_app", "lib/deep_app"}, + {"deep.gemspec", "spec.name = 'deep_gem'\n", "deep_gem", "lib/deep_gem"}, + }; + + char *directory = pkgmap_long_test_value("packages/", 'e', PKGMAP_LONG_TEST_FILL); + ASSERT_NOT_NULL(directory); + const char *failed_case = NULL; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + size_t rel_path_len = strlen(directory) + SKIP_ONE + strlen(cases[i].basename); + char *rel_path = malloc(rel_path_len + SKIP_ONE); + size_t expected_len = strlen(directory) + SKIP_ONE + strlen(cases[i].entry_suffix); + char *expected = malloc(expected_len + SKIP_ONE); + if (!rel_path || !expected) { + free(rel_path); + free(expected); + failed_case = "fixture allocation"; + break; + } + snprintf(rel_path, rel_path_len + SKIP_ONE, "%s/%s", directory, cases[i].basename); + snprintf(expected, expected_len + SKIP_ONE, "%s/%s", directory, cases[i].entry_suffix); + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool parsed = cbm_pkgmap_try_parse(cases[i].basename, rel_path, cases[i].source, + (int)strlen(cases[i].source), &entries); + const char *actual = pkg_entries_entry_for(&entries, cases[i].package_name); + if (!parsed || !actual || strcmp(actual, expected) != 0) { + failed_case = cases[i].basename; + } + cbm_pkg_entries_free(&entries); + free(expected); + free(rel_path); + } + free(directory); + + if (failed_case) { + FAIL(failed_case); + } + PASS(); +} + +/* Maven coordinates form a lookup key, not a display abbreviation. Preserve + * every groupId and artifactId byte so two long coordinates cannot collapse + * to the same truncated package name. */ +TEST(pkgmap_pom_coordinates_are_not_silently_truncated) { + enum { + FORMAT_PLACEHOLDER_BYTES = sizeof("%s") - SKIP_ONE, + }; + char *group_id = pkgmap_long_test_value("com.example.", 'g', PKGMAP_LONG_TEST_FILL); + char *artifact_id = pkgmap_long_test_value("artifact_", 'a', PKGMAP_LONG_TEST_FILL); + if (!group_id || !artifact_id) { + free(artifact_id); + free(group_id); + FAIL("coordinate fixture allocation"); + } + + static const char source_format[] = + "%s%s"; + size_t source_size = strlen(source_format) - PAIR_LEN * FORMAT_PLACEHOLDER_BYTES + + strlen(group_id) + strlen(artifact_id) + SKIP_ONE; + char *source = malloc(source_size); + if (!source) { + free(artifact_id); + free(group_id); + FAIL("POM fixture allocation"); + } + int written = snprintf(source, source_size, source_format, group_id, artifact_id); + bool source_exact = written > 0 && (size_t)written < source_size; + + size_t coordinate_size = strlen(group_id) + SKIP_ONE + strlen(artifact_id) + SKIP_ONE; + char *coordinate = malloc(coordinate_size); + if (!coordinate) { + free(source); + free(artifact_id); + free(group_id); + FAIL("coordinate result allocation"); + } + written = snprintf(coordinate, coordinate_size, "%s.%s", group_id, artifact_id); + bool coordinate_exact = written > 0 && (size_t)written < coordinate_size; + + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + bool parsed = source_exact && coordinate_exact && + cbm_pkgmap_try_parse("pom.xml", "pom.xml", source, (int)strlen(source), &entries); + const char *entry = parsed ? pkg_entries_entry_for(&entries, coordinate) : NULL; + bool exact = entry && strcmp(entry, "src/main/java") == 0; + + cbm_pkg_entries_free(&entries); + free(coordinate); + free(source); + free(artifact_id); + free(group_id); + ASSERT_TRUE(exact); + PASS(); +} + +/* Manifest parsing shares the repository's configurable per-file policy. A + * historical 1 MiB local cap silently discarded valid package metadata even + * when CBM_MAX_FILE_BYTES explicitly allowed the file. */ +TEST(pkgmap_manifest_above_legacy_cap_uses_shared_file_limit) { + enum { + LEGACY_MANIFEST_CAP_BYTES = CBM_SZ_1K * CBM_SZ_1K, + SHARED_MANIFEST_CAP_MIB = 2, + SHARED_MANIFEST_CAP_BYTES = SHARED_MANIFEST_CAP_MIB * LEGACY_MANIFEST_CAP_BYTES, + MANIFEST_WRITE_CHUNK_BYTES = CBM_SZ_4K, + }; + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_large_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir)); + char path[512]; + snprintf(path, sizeof(path), "%s/package.json", tmpdir); + FILE *manifest = cbm_fopen(path, "wb"); + ASSERT_NOT_NULL(manifest); + char padding[MANIFEST_WRITE_CHUNK_BYTES]; + memset(padding, ' ', sizeof(padding)); + size_t remaining = LEGACY_MANIFEST_CAP_BYTES + SKIP_ONE; + while (remaining > 0) { + size_t chunk = remaining < sizeof(padding) ? remaining : sizeof(padding); + ASSERT_EQ(fwrite(padding, SKIP_ONE, chunk, manifest), chunk); + remaining -= chunk; + } + static const char body[] = "{\"name\":\"@org/large\",\"main\":\"src/index.js\"}"; + ASSERT_EQ(fwrite(body, SKIP_ONE, sizeof(body) - SKIP_ONE, manifest), sizeof(body) - SKIP_ONE); + ASSERT_EQ(fclose(manifest), 0); + + const char *saved_raw = getenv("CBM_MAX_FILE_BYTES"); + char *saved = saved_raw ? cbm_strdup(saved_raw) : NULL; + char shared_cap_raw[CBM_SZ_32]; + char legacy_cap_raw[CBM_SZ_32]; + int shared_cap_len = + snprintf(shared_cap_raw, sizeof(shared_cap_raw), "%d", SHARED_MANIFEST_CAP_BYTES); + int legacy_cap_len = + snprintf(legacy_cap_raw, sizeof(legacy_cap_raw), "%d", LEGACY_MANIFEST_CAP_BYTES); + ASSERT(shared_cap_len > 0 && (size_t)shared_cap_len < sizeof(shared_cap_raw)); + ASSERT(legacy_cap_len > 0 && (size_t)legacy_cap_len < sizeof(legacy_cap_raw)); + ASSERT_EQ(cbm_setenv("CBM_MAX_FILE_BYTES", shared_cap_raw, 1), 0); + cbm_pkg_entries_t entries; + cbm_pkg_entries_init(&entries); + int parsed = cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0); + bool found = pkg_entries_has_name(&entries, "@org/large"); + cbm_pkg_entries_free(&entries); + + ASSERT_EQ(cbm_setenv("CBM_MAX_FILE_BYTES", legacy_cap_raw, 1), 0); + pipeline_capture_logs_start(); + cbm_pkg_entries_init(&entries); + int rejected = cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0); + bool rejected_found = pkg_entries_has_name(&entries, "@org/large"); + cbm_pkg_entries_free(&entries); + const char *logs = pipeline_capture_logs_end(); + bool logged_reason = strstr(logs, "msg=pkgmap.manifest_skipped") != NULL && + strstr(logs, "reason=oversized") != NULL && + strstr(logs, "constraint=CBM_MAX_FILE_BYTES") != NULL; + + saved ? cbm_setenv("CBM_MAX_FILE_BYTES", saved, 1) : cbm_unsetenv("CBM_MAX_FILE_BYTES"); + free(saved); + th_rmtree(tmpdir); + + ASSERT_EQ(parsed, 1); + ASSERT_TRUE(found); + ASSERT_EQ(rejected, 0); + ASSERT_FALSE(rejected_found); + ASSERT_TRUE(logged_reason); + PASS(); +} + +TEST(pkgmap_prefix_slash_result_is_not_silently_truncated) { + char *base = pkgmap_long_test_value("proj.", 's', PKGMAP_LONG_TEST_FILL); + ASSERT_NOT_NULL(base); + CBMHashTable *pkgmap = cbm_ht_create(CBM_SZ_16); + ASSERT_NOT_NULL(pkgmap); + cbm_ht_set(pkgmap, cbm_strdup("example.com/root"), cbm_strdup(base)); + cbm_pipeline_set_pkgmap(pkgmap); + cbm_pipeline_ctx_t ctx = {.project_name = "proj"}; + + char *resolved = cbm_pipeline_resolve_module(&ctx, "main.go", "example.com/root/pkg/utils"); + size_t expected_len = strlen(base) + strlen(".pkg.utils"); + char *expected = malloc(expected_len + 1); + ASSERT_NOT_NULL(expected); + snprintf(expected, expected_len + 1, "%s.pkg.utils", base); + ASSERT_NOT_NULL(resolved); + ASSERT_STR_EQ(resolved, expected); + + free(expected); + free(resolved); + free(base); + cbm_pipeline_set_pkgmap(NULL); + cbm_pkgmap_free(pkgmap); + PASS(); +} + +TEST(pkgmap_prefix_dot_result_is_not_silently_truncated) { + char *base = pkgmap_long_test_value("mapped/", 'd', PKGMAP_LONG_TEST_FILL); + ASSERT_NOT_NULL(base); + CBMHashTable *pkgmap = cbm_ht_create(CBM_SZ_16); + ASSERT_NOT_NULL(pkgmap); + cbm_ht_set(pkgmap, cbm_strdup("com.example"), cbm_strdup(base)); + cbm_pipeline_set_pkgmap(pkgmap); + cbm_pipeline_ctx_t ctx = {.project_name = "proj"}; + + char *resolved = cbm_pipeline_resolve_module(&ctx, "Main.java", "com.example.Feature.Type"); + size_t input_len = strlen(base) + strlen("/Feature/Type"); + char *input = malloc(input_len + 1); + ASSERT_NOT_NULL(input); + snprintf(input, input_len + 1, "%s/Feature/Type", base); + char *expected = cbm_pipeline_fqn_module("proj", input); + ASSERT_NOT_NULL(resolved); + ASSERT_NOT_NULL(expected); + ASSERT_STR_EQ(resolved, expected); + + free(expected); + free(input); + free(resolved); + free(base); + cbm_pipeline_set_pkgmap(NULL); + cbm_pkgmap_free(pkgmap); + PASS(); +} + +TEST(pkgmap_prefix_backslash_result_is_not_silently_truncated) { + char *base = pkgmap_long_test_value("mapped/", 'b', PKGMAP_LONG_TEST_FILL); + ASSERT_NOT_NULL(base); + CBMHashTable *pkgmap = cbm_ht_create(CBM_SZ_16); + ASSERT_NOT_NULL(pkgmap); + cbm_ht_set(pkgmap, cbm_strdup("App\\"), cbm_strdup(base)); + cbm_pipeline_set_pkgmap(pkgmap); + cbm_pipeline_ctx_t ctx = {.project_name = "proj"}; + + char *resolved = cbm_pipeline_resolve_module(&ctx, "index.php", "App\\Controllers\\Foo"); + size_t input_len = strlen(base) + strlen("/Controllers/Foo"); + char *input = malloc(input_len + 1); + ASSERT_NOT_NULL(input); + snprintf(input, input_len + 1, "%s/Controllers/Foo", base); + char *expected = cbm_pipeline_fqn_module("proj", input); + ASSERT_NOT_NULL(resolved); + ASSERT_NOT_NULL(expected); + ASSERT_STR_EQ(resolved, expected); + + free(expected); + free(input); + free(resolved); + free(base); + cbm_pipeline_set_pkgmap(NULL); + cbm_pkgmap_free(pkgmap); + PASS(); +} + +/* The env-URL walk must honor discovery exclusions the same way (#792). + * Control run first via the NULL-exclusion wrapper so the exclusion + * assertion cannot pass vacuously. */ +TEST(envscan_walk_honors_discovery_exclusions) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_excl_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("tmpdir"); + + char dir[512]; + snprintf(dir, sizeof(dir), "%s/big_generated", tmpdir); + cbm_mkdir(dir); + + write_temp_file(tmpdir, "deploy.sh", + "#!/bin/bash\nexport CONTROL_URL=\"https://api.example.com/v1\"\n"); + write_temp_file(tmpdir, "big_generated/env.sh", + "#!/bin/bash\nexport EXCLUDED_URL=\"https://excluded.example.com/v1\"\n"); + + /* Control: the NULL-exclusion wrapper sees both bindings. */ + cbm_env_binding_t bindings[32]; + int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL")); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "EXCLUDED_URL")); + + /* With big_generated excluded, its binding must disappear. */ + char *excluded[] = {(char *)"big_generated"}; + count = cbm_scan_project_env_urls_excluded(tmpdir, bindings, 32, excluded, 1); + ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL")); + ASSERT_TRUE(find_binding_by_key(bindings, count, "EXCLUDED_URL") == NULL); + + th_rmtree(tmpdir); + PASS(); +} + +/* ── Git history tests (port of githistory_test.go) ────────────── */ + +/* Port of Go TestIsTrackableFile from githistory_test.go */ +TEST(githistory_is_trackable_file) { + /* Source files — trackable */ + ASSERT_TRUE(cbm_is_trackable_file("main.go")); + ASSERT_TRUE(cbm_is_trackable_file("src/app.py")); + ASSERT_TRUE(cbm_is_trackable_file("README.md")); + + /* node_modules — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("node_modules/foo/bar.js")); + /* vendor — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("vendor/lib/dep.go")); + /* Lock files — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("package-lock.json")); + ASSERT_FALSE(cbm_is_trackable_file("go.sum")); + /* Binary/assets — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("image.png")); + /* .git directory — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file(".git/config")); + /* __pycache__ — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("__pycache__/mod.pyc")); + /* Minified files — not trackable */ + ASSERT_FALSE(cbm_is_trackable_file("src/style.min.css")); + PASS(); +} + +/* Port of Go TestComputeChangeCoupling from githistory_test.go */ +TEST(githistory_compute_change_coupling) { + /* 5 commits: + * aaa: a.go, b.go, c.go + * bbb: a.go, b.go + * ccc: a.go, b.go + * ddd: a.go, c.go + * eee: d.go, e.go + */ + char *files_aaa[] = {"a.go", "b.go", "c.go"}; + char *files_bbb[] = {"a.go", "b.go"}; + char *files_ccc[] = {"a.go", "b.go"}; + char *files_ddd[] = {"a.go", "c.go"}; + char *files_eee[] = {"d.go", "e.go"}; + + cbm_commit_files_t commits[5] = { + {files_aaa, 3, 0}, {files_bbb, 2, 0}, {files_ccc, 2, 0}, + {files_ddd, 2, 0}, {files_eee, 2, 0}, + }; + + cbm_change_coupling_t out[100]; + int count = cbm_compute_change_coupling(commits, 5, out, 100); + + /* a.go + b.go co-change 3 times → should be in results */ + bool found_ab = false; + for (int i = 0; i < count; i++) { + if ((strcmp(out[i].file_a, "a.go") == 0 && strcmp(out[i].file_b, "b.go") == 0) || + (strcmp(out[i].file_a, "b.go") == 0 && strcmp(out[i].file_b, "a.go") == 0)) { + found_ab = true; + ASSERT_EQ(out[i].co_change_count, 3); + ASSERT(out[i].coupling_score >= 0.9); + } + } + ASSERT_TRUE(found_ab); + + /* d.go + e.go co-change only 1 time → below threshold of 3 */ + for (int i = 0; i < count; i++) { + if (strcmp(out[i].file_a, "d.go") == 0 || strcmp(out[i].file_b, "d.go") == 0) { + ASSERT(0); /* d.go should not appear */ + } + } + cbm_change_coupling_paths_free(out, count); + PASS(); +} + +/* Port of Go TestComputeChangeCouplingSkipsLargeCommits from githistory_test.go */ +TEST(githistory_coupling_skips_large_commits) { + /* 25 files in one commit → exceeds 20-file threshold */ + char *files[25]; + char bufs[25][32]; + for (int i = 0; i < 25; i++) { + snprintf(bufs[i], sizeof(bufs[i]), "file%d.go", i); + files[i] = bufs[i]; + } + cbm_commit_files_t commits[1] = {{files, 25, 0}}; + + cbm_change_coupling_t out[100]; + int count = cbm_compute_change_coupling(commits, 1, out, 100); + ASSERT_EQ(count, 0); + PASS(); +} + +/* Port of Go TestComputeChangeCouplingLimitsTo100 from githistory_test.go */ +TEST(githistory_coupling_limits_output) { + /* Generate many small commits to create >100 couplings. + * 50 files, each pair committed 3 times. max_out=100. */ + int idx = 0; + char *pair_files[2450][2]; /* 50*49/2 pairs * 3 repetitions = 3675 commits */ + char pair_bufs[2450][2][32]; + cbm_commit_files_t commits[3675]; + int ci = 0; + for (int i = 0; i < 50 && ci < 3675; i++) { + for (int j = i + 1; j < 50 && ci < 3675; j++) { + for (int k = 0; k < 3 && ci < 3675; k++) { + snprintf(pair_bufs[idx][0], 32, "f%d.go", i); + snprintf(pair_bufs[idx][1], 32, "f%d.go", j); + pair_files[idx][0] = pair_bufs[idx][0]; + pair_files[idx][1] = pair_bufs[idx][1]; + commits[ci].files = pair_files[idx]; + commits[ci].count = 2; + ci++; + idx++; + if (idx >= 2450) + idx = 0; /* reuse buffer space */ + } + } + } + + cbm_change_coupling_t out[200]; + int count = cbm_compute_change_coupling(commits, ci, out, 100); + ASSERT(count <= 100); + cbm_change_coupling_paths_free(out, count); + PASS(); +} + +/* Port of Go TestIsImportReachable from resolver_test.go */ +TEST(registry_is_import_reachable) { + const char *import_vals[] = {"proj.handler", "proj.shared.utils"}; + + /* Exact match: proj.handler.Process → true */ + ASSERT_TRUE(cbm_registry_is_import_reachable("proj.handler.Process", import_vals, 2)); + /* Sub-package: proj.handler.sub.Process → true (handler contains handler) */ + ASSERT_TRUE(cbm_registry_is_import_reachable("proj.handler.sub.Process", import_vals, 2)); + /* Nested match: proj.shared.utils.Helper → true */ + ASSERT_TRUE(cbm_registry_is_import_reachable("proj.shared.utils.Helper", import_vals, 2)); + /* Unrelated: proj.billing.Process → false */ + ASSERT_FALSE(cbm_registry_is_import_reachable("proj.billing.Process", import_vals, 2)); + /* Completely unrelated: unrelated.pkg.Func → false */ + ASSERT_FALSE(cbm_registry_is_import_reachable("unrelated.pkg.Func", import_vals, 2)); + PASS(); +} + +/* Port of FindEndingWith portion from Go TestFunctionRegistry in pipeline_test.go */ +TEST(registry_find_ending_with) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); + cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); + cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); + cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method"); + + /* FindEndingWith "DataProcessor.transform" → 1 match */ + const char **matches = NULL; + int count = cbm_registry_find_ending_with(reg, "DataProcessor.transform", &matches); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(matches[0], "proj.utils.DataProcessor.transform"); + free(matches); + + /* FindEndingWith "Foo" → 2 matches */ + matches = NULL; + count = cbm_registry_find_ending_with(reg, "Foo", &matches); + ASSERT_EQ(count, 2); + /* Both should be present (order may vary) */ + bool found_pkg = false, found_other = false; + for (int i = 0; i < count; i++) { + if (strcmp(matches[i], "proj.pkg.Foo") == 0) + found_pkg = true; + if (strcmp(matches[i], "proj.other.Foo") == 0) + found_other = true; + } + ASSERT_TRUE(found_pkg); + ASSERT_TRUE(found_other); + free(matches); + + /* FindEndingWith "Nonexistent" → 0 matches */ + matches = NULL; + count = cbm_registry_find_ending_with(reg, "Nonexistent", &matches); + ASSERT_EQ(count, 0); + + cbm_registry_free(reg); + PASS(); +} + +/* ═══════════════════════════════════════════════════════════════════ + * Incremental reindex + * ═══════════════════════════════════════════════════════════════════ */ + +/* Helper: create a simple 2-file Go project for incremental tests */ +static char g_incr_tmpdir[256]; +static char g_incr_dbpath[512]; + +static int setup_incremental_repo(void) { + const char *cache = cbm_resolve_cache_dir(); + int n = snprintf(g_incr_tmpdir, sizeof(g_incr_tmpdir), "%s/cbm-incr-XXXXXX", cache); + if (n < 0 || (size_t)n >= sizeof(g_incr_tmpdir) || !cbm_mkdtemp(g_incr_tmpdir)) { + g_incr_tmpdir[0] = '\0'; + return -1; + } + n = snprintf(g_incr_dbpath, sizeof(g_incr_dbpath), "%s/test.db", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(g_incr_dbpath)) { + rm_rf(g_incr_tmpdir); + g_incr_tmpdir[0] = '\0'; + g_incr_dbpath[0] = '\0'; + return -1; + } + + char path[512]; + FILE *f; + + /* main.go — calls Helper() */ + snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); + f = cbm_fopen(path, "wb"); + if (!f) { + rm_rf(g_incr_tmpdir); + g_incr_tmpdir[0] = '\0'; + g_incr_dbpath[0] = '\0'; + return -1; + } + fprintf(f, "package main\n\nfunc main() {\n\tHelper()\n}\n"); + fclose(f); + + /* helper.go — defines Helper() */ + snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + f = cbm_fopen(path, "wb"); + if (!f) { + rm_rf(g_incr_tmpdir); + g_incr_tmpdir[0] = '\0'; + g_incr_dbpath[0] = '\0'; + return -1; + } + fprintf(f, "package main\n\nfunc Helper() string {\n\treturn \"hello\"\n}\n"); + fclose(f); + + return 0; +} + +enum { INCR_PARALLEL_CHANGED_FILE_COUNT = 64 }; + +static int incremental_parallel_file_path(int index, char *path, size_t path_sz) { + int n = snprintf(path, path_sz, "%s/file_%03d.go", g_incr_tmpdir, index); + return (n < 0 || (size_t)n >= path_sz) ? -1 : 0; +} + +static int write_incremental_parallel_file(int index, bool changed) { + char path[CBM_PATH_MAX]; + if (incremental_parallel_file_path(index, path, sizeof(path)) != 0) { + return -1; + } + FILE *f = fopen(path, "w"); + if (!f) { + return -1; + } + fprintf(f, "package main\n\nfunc Func%03d() int {\n\treturn %d\n}\n", index, + index + (changed ? 1 : 0)); + if (changed && index == 0) { + fprintf(f, "\nfunc NewFunc() int {\n\treturn 42\n}\n"); + } + return fclose(f); +} + +static int setup_incremental_parallel_repo(void) { + int n = snprintf(g_incr_tmpdir, sizeof(g_incr_tmpdir), "/tmp/cbm_incr_parallel_XXXXXX"); + if (n < 0 || (size_t)n >= sizeof(g_incr_tmpdir) || !cbm_mkdtemp(g_incr_tmpdir)) { + return -1; + } + n = snprintf(g_incr_dbpath, sizeof(g_incr_dbpath), "%s/test.db", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(g_incr_dbpath)) { + return -1; + } + for (int i = 0; i < INCR_PARALLEL_CHANGED_FILE_COUNT; i++) { + if (write_incremental_parallel_file(i, false) != 0) { + return -1; + } + } + char manifest_path[CBM_PATH_MAX]; + n = snprintf(manifest_path, sizeof(manifest_path), "%s/go.mod", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(manifest_path)) { + return -1; + } + FILE *manifest = fopen(manifest_path, "w"); + if (!manifest) { + return -1; + } + fprintf(manifest, "module example.com/incremental-parallel\n\ngo 1.21\n"); + if (fclose(manifest) != 0) { + return -1; + } + return 0; +} + +static int rewrite_incremental_parallel_repo(void) { + for (int i = 0; i < INCR_PARALLEL_CHANGED_FILE_COUNT; i++) { + if (write_incremental_parallel_file(i, true) != 0) { + return -1; + } + } + return 0; +} + +static void cleanup_incremental_repo(void) { + th_rmtree(g_incr_tmpdir); +} + +static cbm_config_t *incremental_test_config(const char *cache_dir) { + cbm_config_t *cfg = cbm_config_open(cache_dir); + if (cfg) { + cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_REINDEX, CBM_CONFIG_INCREMENTAL_REINDEX_ALWAYS); + } + return cfg; +} + +enum { PIPELINE_INCR_FRONTIER_CALLER_COUNT = CBM_SZ_4 }; + +static int write_incremental_leaf_file(int leaf_value) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + char body[CBM_SZ_512]; + n = snprintf(body, sizeof(body), + "package main\n\n" + "func Leaf() int {\n" + "\tfor i := 0; i < 10; i++ {\n" + "\t\tfor j := 0; j < 10; j++ {\n" + "\t\t}\n" + "\t}\n" + "\treturn %d\n" + "}\n", + leaf_value); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int write_incremental_leaf_file_with_extra(int leaf_value) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + char body[CBM_SZ_512]; + n = snprintf(body, sizeof(body), + "package main\n\n" + "func Leaf() int {\n" + "\tfor i := 0; i < 10; i++ {\n" + "\t\tfor j := 0; j < 10; j++ {\n" + "\t\t}\n" + "\t}\n" + "\treturn %d\n" + "}\n\n" + "func LeafExtra() int {\n" + "\treturn Leaf()\n" + "}\n", + leaf_value); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int write_incremental_frontier_callers(void) { + const char *caller_names[PIPELINE_INCR_FRONTIER_CALLER_COUNT] = { + "caller_a.go", "caller_b.go", "caller_c.go", "caller_d.go"}; + const char *caller_funcs[PIPELINE_INCR_FRONTIER_CALLER_COUNT] = { + "CallerA", "CallerB", "CallerC", "CallerD"}; + char path[CBM_PATH_MAX]; + char body[CBM_SZ_512]; + for (size_t i = 0; i < sizeof(caller_names) / sizeof(caller_names[0]); i++) { + int n = snprintf(path, sizeof(path), "%s/%s", g_incr_tmpdir, caller_names[i]); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + n = snprintf(body, sizeof(body), + "package main\n\n" + "func %s() int {\n" + "\treturn Leaf()\n" + "}\n", + caller_funcs[i]); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + if (th_write_file(path, body) != 0) { + return -1; + } + } + return 0; +} + +static int write_incremental_frontier_fixture(int leaf_value) { + if (write_incremental_leaf_file(leaf_value) != 0) { + return -1; + } + return write_incremental_frontier_callers(); +} + +enum { PIPELINE_INCR_C_HEADER_IMPORTER_COUNT = CBM_SZ_4 }; +static int write_incremental_c_header_frontier_fixture(int marker) { + char path[CBM_PATH_MAX]; + char body[CBM_SZ_1K]; + int n = snprintf(path, sizeof(path), "%s/shared.h", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + n = snprintf(body, sizeof(body), + "#ifndef SHARED_H\n" + "#define SHARED_H\n" + "#define SHARED_MARKER %d\n" + "int shared_value(void);\n" + "#endif\n", + marker); + if (n < 0 || (size_t)n >= sizeof(body) || th_write_file(path, body) != 0) { + return -1; + } + + n = snprintf(path, sizeof(path), "%s/shared.c", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + if (th_write_file(path, + "#include \"shared.h\"\n\n" + "int shared_value(void) {\n" + " return SHARED_MARKER;\n" + "}\n") != 0) { + return -1; + } + + for (int i = 0; i < PIPELINE_INCR_C_HEADER_IMPORTER_COUNT; i++) { + n = snprintf(path, sizeof(path), "%s/consumer_%d.c", g_incr_tmpdir, i); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + n = snprintf(body, sizeof(body), + "#include \"shared.h\"\n\n" + "int consumer_%d(void) {\n" + " return shared_value() + %d;\n" + "}\n", + i, i); + if (n < 0 || (size_t)n >= sizeof(body) || th_write_file(path, body) != 0) { + return -1; + } + } + return 0; +} + +static int write_incremental_c_header_second_level_callers(void) { + char path[CBM_PATH_MAX]; + char body[CBM_SZ_512]; + for (int i = 0; i < PIPELINE_INCR_C_HEADER_IMPORTER_COUNT; i++) { + int n = snprintf(path, sizeof(path), "%s/caller_%d.c", g_incr_tmpdir, i); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + n = snprintf(body, sizeof(body), + "int caller_%d(void) {\n" + " return consumer_%d();\n" + "}\n", + i, i); + if (n < 0 || (size_t)n >= sizeof(body) || th_write_file(path, body) != 0) { + return -1; + } + } + return 0; +} + +static int write_incremental_c_header_extra_export(int marker) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/shared.h", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + char body[CBM_SZ_1K]; + n = snprintf(body, sizeof(body), + "#ifndef SHARED_H\n" + "#define SHARED_H\n" + "#define SHARED_MARKER %d\n" + "int shared_value(void);\n" + "static int shared_extra(void) {\n" + " return SHARED_MARKER + 1;\n" + "}\n" + "#endif\n", + marker); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int write_incremental_c_header_impl_marker(int marker) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/shared.c", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + char body[CBM_SZ_1K]; + n = snprintf(body, sizeof(body), + "#include \"shared.h\"\n\n" + "int shared_value(void) {\n" + " return SHARED_MARKER + %d;\n" + "}\n", + marker); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int write_incremental_c_source_extra_call(int marker) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/shared.c", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + char body[CBM_SZ_1K]; + n = snprintf(body, sizeof(body), + "#include \"shared.h\"\n\n" + "static int shared_extra(void) {\n" + " return %d;\n" + "}\n\n" + "int shared_value(void) {\n" + " return SHARED_MARKER + shared_extra();\n" + "}\n", + marker); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int write_incremental_two_header_additive_fixture(int alpha_marker, int beta_marker, + bool include_extra) { + enum { + PIPELINE_ALPHA_EXTRA_RETURN = 101, + PIPELINE_BETA_EXTRA_RETURN = 202, + }; + char path[CBM_PATH_MAX]; + char body[CBM_SZ_1K]; + char alpha_extra[CBM_SZ_128] = ""; + char beta_extra[CBM_SZ_128] = ""; + if (include_extra) { + int extra_n = snprintf(alpha_extra, sizeof(alpha_extra), + "static int alpha_added(void) {\n" + " return %d;\n" + "}\n", + PIPELINE_ALPHA_EXTRA_RETURN); + if (extra_n < 0 || (size_t)extra_n >= sizeof(alpha_extra)) { + return -1; + } + extra_n = snprintf(beta_extra, sizeof(beta_extra), + "static int beta_added(void) {\n" + " return %d;\n" + "}\n", + PIPELINE_BETA_EXTRA_RETURN); + if (extra_n < 0 || (size_t)extra_n >= sizeof(beta_extra)) { + return -1; + } + } + int n = snprintf(path, sizeof(path), "%s/alpha.h", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + n = snprintf(body, sizeof(body), + "#ifndef ALPHA_H\n" + "#define ALPHA_H\n" + "static int alpha_existing(void) {\n" + " return %d;\n" + "}\n" + "%s" + "#endif\n", + alpha_marker, alpha_extra); + if (n < 0 || (size_t)n >= sizeof(body) || th_write_file(path, body) != 0) { + return -1; + } + + n = snprintf(path, sizeof(path), "%s/beta.h", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + n = snprintf(body, sizeof(body), + "#ifndef BETA_H\n" + "#define BETA_H\n" + "static int beta_existing(void) {\n" + " return %d;\n" + "}\n" + "%s" + "#endif\n", + beta_marker, beta_extra); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int write_incremental_arg_url_route_file(const char *route_path, int marker) { + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/http_routes.c", g_incr_tmpdir); + if (n < 0 || (size_t)n >= sizeof(path)) { + return -1; + } + char body[CBM_SZ_1K]; + n = snprintf(body, sizeof(body), + "static int cbm_http_path_match(const char *path, const char *pattern) {\n" + " return path && pattern;\n" + "}\n\n" + "int dispatch_request(const char *path) {\n" + " if (cbm_http_path_match(path, \"%s\")) {\n" + " return %d;\n" + " }\n" + " return 0;\n" + "}\n", + route_path, marker); + if (n < 0 || (size_t)n >= sizeof(body)) { + return -1; + } + return th_write_file(path, body); +} + +static int pipeline_store_insert_file_owned_unowned_source_edge(const char *db_path, + const char *project, + const char *rel_path, + const char *target_name, + const char *edge_type) { + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + return CBM_STORE_ERR; + } + /* Resolve the fixture target by its persisted identity rather than + * reconstructing a language-specific FQN. Go and Java use directory + * modules, while other languages retain filename-stem modules. */ + cbm_node_t *named_nodes = NULL; + int named_count = 0; + int rc = cbm_store_find_nodes_by_name(s, project, target_name, &named_nodes, &named_count); + int64_t target_id = CBM_STORE_NO_NODE_ID; + if (rc == CBM_STORE_OK) { + for (int i = 0; i < named_count; i++) { + if (named_nodes[i].file_path && strcmp(named_nodes[i].file_path, rel_path) == 0) { + if (target_id != CBM_STORE_NO_NODE_ID) { + target_id = CBM_STORE_NO_NODE_ID; + break; + } + target_id = named_nodes[i].id; + } + } + } + cbm_store_free_nodes(named_nodes, named_count); + if (rc != CBM_STORE_OK || target_id == CBM_STORE_NO_NODE_ID) { + cbm_store_close(s); + return rc == CBM_STORE_OK ? CBM_STORE_NOT_FOUND : rc; + } + char source_qn[CBM_SZ_512]; + int n = snprintf(source_qn, sizeof(source_qn), "%s.__unowned_source", project); + if (n < 0 || (size_t)n >= sizeof(source_qn)) { + cbm_store_close(s); + return CBM_STORE_ERR; + } + cbm_node_t source = {.project = (char *)project, + .label = "Module", + .name = "unowned_source", + .qualified_name = source_qn, + .file_path = "", + .properties_json = "{}"}; + int64_t source_id = cbm_store_upsert_node(s, &source); + if (source_id <= CBM_STORE_NO_NODE_ID) { + cbm_store_close(s); + return CBM_STORE_ERR; + } + cbm_edge_t edge = {.project = (char *)project, + .source_id = source_id, + .target_id = target_id, + .type = (char *)edge_type, + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &edge); + if (edge_id <= CBM_STORE_NO_NODE_ID) { + cbm_store_close(s); + return CBM_STORE_ERR; + } + rc = cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL, + CBM_PIPELINE_COMPAT_GENERATION); + cbm_store_close(s); + return rc; +} + +static int pipeline_store_has_node_name_by_label(const char *db_path, const char *project, + const char *label, const char *name) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return 0; + } + cbm_node_t *nodes = NULL; + int count = 0; + int found = 0; + if (cbm_store_find_nodes_by_label(s, project, label, &nodes, &count) == CBM_STORE_OK) { + for (int i = 0; i < count; i++) { + if (nodes[i].name && strcmp(nodes[i].name, name) == 0) { + found = 1; + break; + } + } + cbm_store_free_nodes(nodes, count); + } + cbm_store_close(s); + return found; +} + +static int pipeline_store_has_function_name(const char *db_path, const char *project, + const char *name) { + return pipeline_store_has_node_name_by_label(db_path, project, "Function", name); +} + +static int pipeline_store_has_route_name(const char *db_path, const char *project, + const char *name) { + return pipeline_store_has_node_name_by_label(db_path, project, "Route", name); +} + +static int pipeline_restore_file_times(const char *path, const struct stat *st) { + if (!path || !st) { + return -1; + } +#ifdef _WIN32 + struct __utimbuf64 times = {.actime = st->st_atime, .modtime = st->st_mtime}; + return _utime64(path, ×); +#else + struct timespec times[CBM_SZ_2]; +#ifdef __APPLE__ + times[0] = st->st_atimespec; + times[SKIP_ONE] = st->st_mtimespec; +#else + times[0] = st->st_atim; + times[SKIP_ONE] = st->st_mtim; +#endif + return utimensat(AT_FDCWD, path, times, 0); +#endif +} + +enum { PIPELINE_TEST_MTIME_BUMP_SECONDS = 2 }; + +static int pipeline_bump_file_mtime_seconds(const char *path, const struct stat *st, long seconds) { + if (!path || !st) { + return -1; + } + struct stat bumped = *st; +#ifdef _WIN32 + bumped.st_mtime += seconds; +#elif defined(__APPLE__) + bumped.st_mtimespec.tv_sec += seconds; +#else + bumped.st_mtim.tv_sec += seconds; +#endif + return pipeline_restore_file_times(path, &bumped); +} + +static int pipeline_store_file_hash_count(const char *db_path, const char *project) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + cbm_file_hash_t *hashes = NULL; + int count = 0; + int rc = cbm_store_get_file_hashes(s, project, &hashes, &count); + cbm_store_free_file_hashes(hashes, count); + cbm_store_close(s); + return rc == CBM_STORE_OK ? count : CBM_STORE_ERR; +} + +static int pipeline_store_file_hash_mtime(const char *db_path, const char *project, + const char *rel_path, int64_t *out_mtime_ns) { + if (!out_mtime_ns) { + return CBM_STORE_ERR; + } + *out_mtime_ns = 0; + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + cbm_file_hash_t *hashes = NULL; + int count = 0; + int rc = cbm_store_get_file_hashes(s, project, &hashes, &count); + if (rc == CBM_STORE_OK) { + rc = CBM_STORE_NOT_FOUND; + for (int i = 0; i < count; i++) { + if (hashes[i].rel_path && strcmp(hashes[i].rel_path, rel_path) == 0) { + *out_mtime_ns = hashes[i].mtime_ns; + rc = CBM_STORE_OK; + break; + } + } + } + cbm_store_free_file_hashes(hashes, count); + cbm_store_close(s); + return rc; +} + +static int pipeline_store_file_state_generation(const char *db_path, const char *project, + const char *rel_path, int64_t *out_generation) { + if (!out_generation) { + return CBM_STORE_ERR; + } + *out_generation = 0; + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + cbm_file_state_t state = {0}; + int rc = cbm_store_get_file_state(s, project, rel_path, &state); + if (rc == CBM_STORE_OK) { + *out_generation = state.generation; + cbm_store_file_state_free_fields(&state); + } + cbm_store_close(s); + return rc; +} + +static int pipeline_store_dirty_counts(const char *db_path, const char *project, + int *out_pending, int *out_overlay_ready) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + int rc = cbm_store_count_dirty_files(s, project, out_pending, out_overlay_ready); + cbm_store_close(s); + return rc; +} + +static bool pipeline_store_overlay_call_connected(const char *db_path, const char *project, + const char *source_name, + const char *target_name) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return false; + } + cbm_node_t *sources = NULL; + int source_count = 0; + bool found = false; + if (cbm_store_find_nodes_by_name_overlay_view(s, project, source_name, &sources, + &source_count) == CBM_STORE_OK) { + const char *edge_types[] = {"CALLS"}; + for (int i = 0; i < source_count && !found; i++) { + if (!sources[i].qualified_name) { + continue; + } + cbm_traverse_result_t trace = {0}; + if (cbm_store_bfs_overlay_view(s, project, sources[i].qualified_name, "outbound", + edge_types, 1, 1, CBM_SZ_64, &trace) != + CBM_STORE_OK) { + continue; + } + for (int j = 0; j < trace.visited_count; j++) { + if (trace.visited[j].node.name && + strcmp(trace.visited[j].node.name, target_name) == 0) { + found = true; + break; + } + } + cbm_store_traverse_free(&trace); + } + } + cbm_store_free_nodes(sources, source_count); + cbm_store_close(s); + return found; +} + +static int pipeline_store_overlay_file_function_count(const char *db_path, const char *project, + const char *rel_path, const char *name) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + cbm_node_t *nodes = NULL; + int count = 0; + int matches = 0; + if (cbm_store_find_nodes_by_file_overlay_view(s, project, rel_path, &nodes, &count) != + CBM_STORE_OK) { + cbm_store_close(s); + return CBM_STORE_ERR; + } + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, "Function") == 0 && nodes[i].name && + strcmp(nodes[i].name, name) == 0) { + matches++; + } + } + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); + return matches; +} + +static int pipeline_store_overlay_file_has_function(const char *db_path, const char *project, + const char *rel_path, const char *name) { + return pipeline_store_overlay_file_function_count(db_path, project, rel_path, name) > 0; +} + +static int pipeline_store_generation_status_count(const char *db_path, const char *project, + const char *status) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *stmt = NULL; + const char *sql = "SELECT COUNT(*) FROM index_generations " + "WHERE project = ?1 AND status = ?2 AND generation > ?3;"; + int count = CBM_STORE_ERR; + if (db && sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, project, CBM_NOT_FOUND, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, status, CBM_NOT_FOUND, SQLITE_TRANSIENT); + sqlite3_bind_int64(stmt, 3, CBM_PIPELINE_COMPAT_GENERATION); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + } + sqlite3_finalize(stmt); + cbm_store_close(s); + return count; +} + +static int pipeline_store_completed_generation_count(const char *db_path, const char *project) { + return pipeline_store_generation_status_count(db_path, project, + CBM_STORE_INDEX_STATUS_COMPLETE); +} + +static int pipeline_store_overlay_generation_status_count(const char *db_path, + const char *project, + const char *status) { + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + int count = CBM_STORE_ERR; + if (cbm_store_count_overlay_generations(s, project, status, &count) != CBM_STORE_OK) { + count = CBM_STORE_ERR; + } + cbm_store_close(s); + return count; +} + +static int pipeline_store_count_file_rows_sql(const char *db_path, const char *project, + const char *rel_path, const char *sql, + int *out_count) { + if (!db_path || !project || !rel_path || !sql || !out_count) { + return CBM_STORE_ERR; + } + *out_count = 0; + cbm_store_t *s = cbm_store_open_path_query(db_path); + if (!s) { + return CBM_STORE_ERR; + } + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *stmt = NULL; + int rc = CBM_STORE_ERR; + if (db && sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, project, CBM_NOT_FOUND, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, rel_path, CBM_NOT_FOUND, SQLITE_TRANSIENT); + if (sqlite3_step(stmt) == SQLITE_ROW) { + *out_count = sqlite3_column_int(stmt, 0); + rc = CBM_STORE_OK; + } + } + sqlite3_finalize(stmt); + cbm_store_close(s); + return rc; +} + +static int pipeline_compare_current_db_to_fresh_rebuild(const char *repo_path, const char *db_path, + const char *project, + cbm_index_mode_t rebuild_mode, + cbm_config_t *cfg, char *err, + size_t err_sz) { + char incremental_snapshot_db[CBM_SZ_512]; + int n = snprintf(incremental_snapshot_db, sizeof(incremental_snapshot_db), + "%s/canonical-incremental-snapshot.db", repo_path); + if (n < 0 || (size_t)n >= sizeof(incremental_snapshot_db)) { + if (err && err_sz > 0) { + snprintf(err, err_sz, "canonical incremental snapshot path overflow"); + } + return CBM_STORE_ERR; + } + cbm_unlink(incremental_snapshot_db); + int rc = pipeline_dump_store_file_to_file(db_path, incremental_snapshot_db); + if (rc != CBM_STORE_OK) { + if (err && err_sz > 0) { + snprintf(err, err_sz, "canonical incremental snapshot dump failed: rc=%d", rc); + } + cbm_unlink(incremental_snapshot_db); + return rc; + } + + cbm_unlink(db_path); + cbm_pipeline_t *p = cbm_pipeline_new(repo_path, db_path, rebuild_mode); + if (!p) { + if (err && err_sz > 0) { + snprintf(err, err_sz, "fresh mode %d pipeline allocation failed", rebuild_mode); + } + cbm_unlink(incremental_snapshot_db); + return CBM_STORE_ERR; + } + cbm_pipeline_apply_config(p, cfg); + int run_rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + if (run_rc != 0) { + if (err && err_sz > 0) { + snprintf(err, err_sz, "fresh mode %d rebuild failed: rc=%d", rebuild_mode, run_rc); + } + cbm_unlink(incremental_snapshot_db); + return CBM_STORE_ERR; + } + + rc = cbm_test_compare_canonical_graphs(incremental_snapshot_db, db_path, project, err, err_sz); + if (rc == 0) { + cbm_unlink(incremental_snapshot_db); + } + return rc; +} + +static int pipeline_compare_current_db_to_fresh_fast_rebuild(const char *repo_path, + const char *db_path, + const char *project, cbm_config_t *cfg, + char *err, size_t err_sz) { + return pipeline_compare_current_db_to_fresh_rebuild(repo_path, db_path, project, CBM_MODE_FAST, + cfg, err, err_sz); +} + +static int pipeline_gbuf_count_usage_edge(const cbm_gbuf_t *gb, const char *source_qn, + const char *target_qn, const char *callee) { + const cbm_gbuf_node_t *src = cbm_gbuf_find_by_qn(gb, source_qn); + const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_qn(gb, target_qn); + if (!src || !tgt) { + return 0; + } + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (cbm_gbuf_find_edges_by_source_type(gb, src->id, "USAGE", &edges, &edge_count) != 0) { + return 0; + } + int matches = 0; + for (int i = 0; i < edge_count; i++) { + const cbm_gbuf_edge_t *edge = edges[i]; + if (edge && edge->target_id == tgt->id && + (!callee || (edge->properties_json && strstr(edge->properties_json, callee)))) { + matches++; + } + } + return matches; +} + +static int pipeline_file_delta_count_usage_edge(const cbm_pipeline_file_delta_t *delta, + const char *source_qn, const char *target_qn, + const char *callee) { + int matches = 0; + for (int i = 0; i < delta->delta.edge_count; i++) { + const cbm_store_delta_edge_t *edge = &delta->edges[i]; + if (edge->type && strcmp(edge->type, "USAGE") == 0 && + edge->source_qn && strcmp(edge->source_qn, source_qn) == 0 && + edge->target_qn && strcmp(edge->target_qn, target_qn) == 0 && + (!callee || (edge->properties_json && strstr(edge->properties_json, callee)))) { + matches++; + } + } + return matches; +} + +static int pipeline_file_delta_count_call_edge(const cbm_pipeline_file_delta_t *delta, + const char *source_qn, const char *target_qn, + const char *callee, const char *strategy) { + int matches = 0; + for (int i = 0; i < delta->delta.edge_count; i++) { + const cbm_store_delta_edge_t *edge = &delta->edges[i]; + if (edge->type && strcmp(edge->type, "CALLS") == 0 && + edge->source_qn && strcmp(edge->source_qn, source_qn) == 0 && + edge->target_qn && strcmp(edge->target_qn, target_qn) == 0 && + (!callee || (edge->properties_json && strstr(edge->properties_json, callee))) && + (!strategy || (edge->properties_json && strstr(edge->properties_json, strategy)))) { + matches++; + } + } + return matches; +} + +static const char *pipeline_exact_scratch_structure_root_qn(const cbm_gbuf_t *gbuf, + const char *project) { + const cbm_gbuf_node_t **branches = NULL; + int branch_count = 0; + if (cbm_gbuf_find_by_label(gbuf, "Branch", &branches, &branch_count) == 0 && + branch_count > 0 && branches[0]->qualified_name) { + return branches[0]->qualified_name; + } + return project; +} + +static int pipeline_build_exact_scratch_for_changed_files_ex( + cbm_store_t *store, const char *repo_path, const char *project, + cbm_file_info_t *changed_files, int changed_count, const cbm_file_info_t *all_files, + int all_file_count, int store_backed_lsp_scope_cap, cbm_gbuf_t **out_scratch, + cbm_pipeline_file_delta_t *deltas) { + if (out_scratch) { + *out_scratch = NULL; + } + if (!store || !repo_path || !project || !changed_files || changed_count <= 0 || + !out_scratch || !deltas) { + return CBM_STORE_ERR; + } + + const char **changed_paths = calloc((size_t)changed_count, sizeof(*changed_paths)); + CBMFileResult **result_cache = calloc((size_t)changed_count, sizeof(*result_cache)); + cbm_gbuf_t *scratch = cbm_gbuf_new(project, repo_path); + cbm_registry_t *registry = cbm_registry_new(); + cbm_path_alias_collection_t *path_aliases = NULL; + CBMHashTable *pkgmap = NULL; + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = {0}; + int rc = CBM_STORE_ERR; + if (!changed_paths || !result_cache || !scratch || !registry) { + goto cleanup; + } + for (int i = 0; i < changed_count; i++) { + changed_paths[i] = changed_files[i].rel_path; + } + rc = cbm_pipeline_seed_file_delta_scratch_from_store(store, scratch, registry, project, + changed_paths, changed_count); + if (rc != CBM_STORE_OK) { + goto cleanup; + } + + path_aliases = cbm_load_path_aliases(repo_path); + pkgmap = + cbm_pkgmap_build_from_repo(repo_path, changed_files, changed_count, project, NULL, 0); + cbm_pipeline_set_pkgmap(pkgmap); + + const double pipeline_default_threshold = 0.0; /* Pipeline constructor sentinel: use pass defaults. */ + ctx = (cbm_pipeline_ctx_t){.project_name = project, + .repo_path = repo_path, + .gbuf = scratch, + .registry = registry, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .similarity_threshold = pipeline_default_threshold, + .httplink_min_confidence = pipeline_default_threshold, + .semantic_threshold = pipeline_default_threshold, + .githistory_min_coupling = pipeline_default_threshold, + .lsp_confidence_floor = pipeline_default_threshold, + .path_aliases = path_aliases, + .result_cache = result_cache, + .store_backed_node_lookup = store, + .store_backed_changed_paths = changed_paths, + .store_backed_changed_path_count = changed_count, + .store_backed_all_files = all_files, + .store_backed_all_file_count = all_file_count, + .store_backed_lsp_scope_cap = store_backed_lsp_scope_cap}; + const char *structure_root_qn = pipeline_exact_scratch_structure_root_qn(scratch, project); + for (int i = 0; i < changed_count; i++) { + if (cbm_pipeline_ensure_file_structure(scratch, project, structure_root_qn, + changed_files[i].rel_path, NULL) != 0) { + goto cleanup; + } + } + if (cbm_pipeline_pass_definitions(&ctx, changed_files, changed_count) != 0 || + cbm_pipeline_pass_lsp_cross(&ctx, changed_files, changed_count, result_cache) != 0 || + cbm_pipeline_pass_calls(&ctx, changed_files, changed_count) != 0 || + cbm_pipeline_pass_usages(&ctx, changed_files, changed_count) != 0 || + cbm_pipeline_pass_semantic(&ctx, changed_files, changed_count) != 0 || + cbm_pipeline_pass_k8s(&ctx, changed_files, changed_count) != 0 || + cbm_pipeline_pass_tests(&ctx, changed_files, changed_count) != 0) { + goto cleanup; + } + (void)cbm_pipeline_pass_decorator_tags(scratch, project); + (void)cbm_pipeline_pass_configlink(&ctx); + cbm_pipeline_clear_route_derived_edges(scratch); + cbm_pipeline_create_route_nodes(scratch); + cbm_pipeline_pass_complexity_for_paths(&ctx, changed_paths, changed_count); + if (cbm_pipeline_pass_httplinks(&ctx) != 0) { + goto cleanup; + } + cbm_pipeline_pass_normalize(scratch); + for (int i = 0; i < changed_count; i++) { + rc = cbm_pipeline_build_file_delta_from_gbuf(scratch, project, changed_files[i].rel_path, + CBM_PIPELINE_COMPAT_GENERATION, &deltas[i]); + if (rc != CBM_STORE_OK) { + goto cleanup; + } + } + + *out_scratch = scratch; + scratch = NULL; + rc = CBM_STORE_OK; + +cleanup: + if (ctx.seq_cross_arena_live) { + cbm_arena_destroy(&ctx.seq_cross_arena); + ctx.seq_cross_arena_live = false; + } + for (int i = 0; i < changed_count; i++) { + if (result_cache && result_cache[i]) { + cbm_free_result(result_cache[i]); + } + } + free(result_cache); + cbm_path_alias_collection_free(path_aliases); + if (cbm_pipeline_get_pkgmap() == pkgmap) { + cbm_pipeline_set_pkgmap(NULL); + } + cbm_pkgmap_free(pkgmap); + cbm_registry_free(registry); + cbm_gbuf_free(scratch); + free(changed_paths); + return rc; +} + +static int pipeline_build_exact_scratch_for_changed_files(cbm_store_t *store, + const char *repo_path, + const char *project, + cbm_file_info_t *changed_files, + int changed_count, + cbm_gbuf_t **out_scratch, + cbm_pipeline_file_delta_t *deltas) { + return pipeline_build_exact_scratch_for_changed_files_ex( + store, repo_path, project, changed_files, changed_count, NULL, 0, 0, out_scratch, + deltas); +} + +/* Atomic-publish cancellation seam. Production invokes this hook after the + * staging database is complete, closed, and integrity-valid, but immediately + * before it can replace the last committed database. Keeping the hook on the + * pipeline instance avoids process-global failpoints and makes cancellation + * tests deterministic even when test runners gain concurrency. */ +extern void cbm_pipeline_set_before_publish_hook_for_tests( + cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx); + +typedef struct { + const char *project; + const char *candidate; + char staging_path[768]; + int calls; + bool staging_existed; + bool staging_was_valid; + int staged_candidates; +} publish_cancel_ctx_t; + +typedef struct { + char staging_path[CBM_SZ_4K]; + int calls; + bool staging_was_valid; +} publish_observe_ctx_t; + +static void observe_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) { + (void)p; + publish_observe_ctx_t *ctx = (publish_observe_ctx_t *)arg; + ctx->calls++; + if (!staging_path) { + return; + } + int n = snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path); + if (n < 0 || (size_t)n >= sizeof(ctx->staging_path)) { + ctx->staging_path[0] = '\0'; + return; + } + cbm_store_t *staging = cbm_store_open_path_existing(staging_path); + if (staging) { + ctx->staging_was_valid = cbm_store_check_integrity(staging); + cbm_store_close(staging); + } +} + +typedef struct { + int calls; +} publish_rename_fail_ctx_t; + +static int fail_publish_rename(const char *staging_path, const char *final_path, void *arg) { + publish_rename_fail_ctx_t *ctx = (publish_rename_fail_ctx_t *)arg; + ctx->calls++; + return staging_path && final_path ? CBM_NOT_FOUND : 0; +} + +static bool pipeline_fixture_file_equals(const char *path, const char *expected) { + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + return false; + } + size_t expected_len = strlen(expected); + char actual[64]; + size_t n = fread(actual, 1, sizeof(actual), f); + bool ok = n == expected_len && memcmp(actual, expected, expected_len) == 0 && fgetc(f) == EOF; + (void)fclose(f); + return ok; +} + +static void cancel_at_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) { + publish_cancel_ctx_t *ctx = (publish_cancel_ctx_t *)arg; + ctx->calls++; + if (staging_path && staging_path[0]) { + snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path); + struct stat st; + ctx->staging_existed = stat(staging_path, &st) == 0; + if (ctx->staging_existed) { + cbm_store_t *staging = cbm_store_open_path(staging_path); + if (staging) { + ctx->staging_was_valid = cbm_store_check_integrity(staging); + ctx->staged_candidates = count_nodes_named(staging, ctx->project, ctx->candidate); + cbm_store_close(staging); + } + } + } + cbm_pipeline_cancel(p); +} + +static bool sqlite_artifacts_absent(const char *db_path) { + struct stat st; + if (!db_path || !db_path[0] || stat(db_path, &st) == 0) { + return false; + } + char sidecar[832]; + snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path); + if (stat(sidecar, &st) == 0) { + return false; + } + snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path); + if (stat(sidecar, &st) == 0) { + return false; + } + snprintf(sidecar, sizeof(sidecar), "%s-journal", db_path); + return stat(sidecar, &st) != 0; +} + +static bool write_go_file(const char *dir, const char *name, const char *source) { + char path[512]; + snprintf(path, sizeof(path), "%s/%s", dir, name); + FILE *f = fopen(path, "w"); + if (!f) { + return false; + } + bool ok = fputs(source, f) >= 0; + fclose(f); + return ok; +} + +/* ═══════════════════════════════════════════════════════════════════ + * FastAPI Depends() edge tracking (PR #66, fix #27) + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(pipeline_fastapi_depends_edges) { + /* Depends(get_current_user) should produce a CALLS edge from the + * endpoint to the dependency function. */ + const char *files[] = {"auth.py", "routes.py"}; + const char *contents[] = {/* auth.py: defines get_current_user */ + "def get_current_user(token: str):\n" + " return decode_token(token)\n", + /* routes.py: endpoint depends on get_current_user */ + "from fastapi import Depends\n" + "from auth import get_current_user\n\n" + "def get_profile(user = Depends(get_current_user)):\n" + " return {\"user\": user}\n"}; + if (setup_lang_repo(files, contents, 2) != 0) { + FAIL("tmpdir"); + } + char db[512]; + snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + const char *proj = cbm_pipeline_project_name(p); + + /* Check CALLS edges for fastapi_depends strategy */ + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, proj, "CALLS", &edges, &edge_count); + + bool found_depends_edge = false; + for (int i = 0; i < edge_count; i++) { + if (edges[i].properties_json && strstr(edges[i].properties_json, "fastapi_depends")) { + found_depends_edge = true; + break; + } + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + ASSERT_TRUE(found_depends_edge); + + cbm_store_close(s); + cbm_pipeline_free(p); + teardown_lang_repo(); + PASS(); +} + +TEST(import_edge_helper_escapes_local_name_once) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + + int64_t source_file = cbm_gbuf_upsert_node(gb, "File", "main.py", + "proj.main.py.__file__", "main.py", 1, 1, "{}"); + int64_t target_fn = + cbm_gbuf_upsert_node(gb, "Function", "factory", "proj.pkg.factory", "pkg.py", 1, 1, "{}"); + ASSERT_GT(source_file, 0); + ASSERT_GT(target_fn, 0); + + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(gb, target_fn); + ASSERT_NOT_NULL(target); + + cbm_pipeline_ctx_t ctx = { + .gbuf = gb, + .project_name = "proj", + }; + const char alias[] = "quoted\"alias\\module\nnext\tfield"; + ASSERT_EQ(cbm_pipeline_insert_import_edge(&ctx, source_file, target, alias), 1); + ASSERT_EQ(cbm_pipeline_insert_import_edge(&ctx, source_file, cbm_gbuf_find_by_id(gb, source_file), + "self"), 0); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_source_type(gb, source_file, "IMPORTS", &edges, &edge_count), + 0); + ASSERT_EQ(edge_count, 1); + ASSERT_EQ(edges[0]->target_id, target_fn); + + yyjson_doc *doc = + yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *local = yyjson_obj_get(root, "local_name"); + ASSERT_NOT_NULL(local); + ASSERT_STR_EQ(yyjson_get_str(local), alias); + yyjson_doc_free(doc); + + const char **keys = NULL; + const char **vals = NULL; + int import_count = 0; + ASSERT_EQ(cbm_pipeline_build_import_map_from_edges(gb, "proj", "main.py", &keys, &vals, + &import_count), + 0); + ASSERT_EQ(import_count, 1); + ASSERT_STR_EQ(keys[0], alias); + ASSERT_STR_EQ(vals[0], target->qualified_name); + cbm_pipeline_free_import_map(keys, vals, import_count); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(import_edge_helper_preserves_long_local_name) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + + int64_t source_file = cbm_gbuf_upsert_node(gb, "File", "main.py", + "proj.main.py.__file__", "main.py", 1, 1, "{}"); + int64_t target_fn = + cbm_gbuf_upsert_node(gb, "Function", "factory", "proj.pkg.factory", "pkg.py", 1, 1, "{}"); + ASSERT_GT(source_file, 0); + ASSERT_GT(target_fn, 0); + + const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(gb, target_fn); + ASSERT_NOT_NULL(target); + + enum { LONG_ALIAS_LEN = CBM_SZ_256 + CBM_SZ_64 }; + char alias[LONG_ALIAS_LEN + SKIP_ONE]; + for (int i = 0; i < LONG_ALIAS_LEN; i++) { + alias[i] = (char)('a' + (i % CBM_DECIMAL_BASE)); + } + alias[LONG_ALIAS_LEN] = '\0'; + + cbm_pipeline_ctx_t ctx = { + .gbuf = gb, + .project_name = "proj", + }; + ASSERT_EQ(cbm_pipeline_insert_import_edge(&ctx, source_file, target, alias), 1); + + const char **keys = NULL; + const char **vals = NULL; + int import_count = 0; + ASSERT_EQ(cbm_pipeline_build_import_map_from_edges(gb, "proj", "main.py", &keys, &vals, + &import_count), + 0); + ASSERT_EQ(import_count, 1); + ASSERT_STR_EQ(keys[0], alias); + ASSERT_STR_EQ(vals[0], target->qualified_name); + cbm_pipeline_free_import_map(keys, vals, import_count); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(import_map_from_edges_follows_package_reexport) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + + int64_t source_file = cbm_gbuf_upsert_node(gb, "File", "main.py", + "proj.app.main.py.__file__", "app/main.py", 1, + 1, "{}"); + int64_t package_module = + cbm_gbuf_upsert_node(gb, "Folder", "fastapi", "proj.fastapi", "fastapi", 1, + 1, "{}"); + int64_t package_file = + cbm_gbuf_upsert_node(gb, "File", "__init__.py", "proj.fastapi.__init__.py.__file__", + "fastapi/__init__.py", 1, 1, "{}"); + int64_t wrong_header = cbm_gbuf_upsert_node(gb, "Class", "Header", + "proj.fastapi.openapi.models.Header", + "fastapi/openapi/models.py", 1, 1, "{}"); + int64_t exported_header = + cbm_gbuf_upsert_node(gb, "Function", "Header", "proj.fastapi.param_functions.Header", + "fastapi/param_functions.py", 1, 1, "{}"); + ASSERT_GT(source_file, 0); + ASSERT_GT(package_module, 0); + ASSERT_GT(package_file, 0); + ASSERT_GT(wrong_header, 0); + ASSERT_GT(exported_header, 0); + + cbm_pipeline_ctx_t ctx = { + .gbuf = gb, + .project_name = "proj", + }; + ASSERT_EQ(cbm_pipeline_insert_import_edge(&ctx, source_file, + cbm_gbuf_find_by_id(gb, package_module), "Header"), + 1); + cbm_gbuf_insert_edge(gb, package_file, exported_header, "IMPORTS", + "{\"local_name\":\"Header\"}"); + + const char **keys = NULL; + const char **vals = NULL; + int import_count = 0; + ASSERT_EQ(cbm_pipeline_build_import_map_from_edges(gb, "proj", "app/main.py", &keys, &vals, + &import_count), + 0); + ASSERT_EQ(import_count, 1); + ASSERT_STR_EQ(keys[0], "Header"); + ASSERT_STR_EQ(vals[0], "proj.fastapi.param_functions.Header"); + ASSERT_TRUE(cbm_pipeline_import_map_entry_is_reexport( + gb, "proj", "app/main.py", "Header", "proj.fastapi.param_functions.Header")); + ASSERT_FALSE(cbm_pipeline_import_map_entry_is_reexport( + gb, "proj", "app/main.py", "Header", "proj.fastapi.openapi.models.Header")); + + cbm_pipeline_free_import_map(keys, vals, import_count); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(import_reexport_falls_back_when_pkgmap_target_missing) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + + int64_t openapi_header = cbm_gbuf_upsert_node( + gb, "Class", "Header", "proj.fastapi.openapi.models.Header", "fastapi/openapi/models.py", 1, + 1, "{}"); + int64_t package_file = cbm_gbuf_upsert_node(gb, "File", "__init__.py", "proj.fastapi.__file__", + "fastapi/__init__.py", 1, 1, "{}"); + int64_t test_header = cbm_gbuf_upsert_node( + gb, "Class", "Header", "proj.tests.test_headers.Header", "tests/test_headers.py", 1, 1, + "{}"); + int64_t exported_header = cbm_gbuf_upsert_node( + gb, "Function", "Header", "proj.fastapi.param_functions.Header", "fastapi/param_functions.py", + 1, 1, "{}"); + ASSERT_GT(openapi_header, 0); + ASSERT_GT(package_file, 0); + ASSERT_GT(test_header, 0); + ASSERT_GT(exported_header, 0); + cbm_gbuf_insert_edge(gb, package_file, test_header, "IMPORTS", "{\"local_name\":\"Header\"}"); + cbm_gbuf_insert_edge(gb, package_file, exported_header, "IMPORTS", "{\"local_name\":\"Header\"}"); + + CBMHashTable *pkgmap = cbm_ht_create(CBM_SZ_16); + ASSERT_NOT_NULL(pkgmap); + cbm_ht_set(pkgmap, strdup("fastapi"), strdup("proj.src.fastapi.__init__")); + cbm_pipeline_set_pkgmap(pkgmap); + + cbm_pipeline_ctx_t ctx = { + .gbuf = gb, + .project_name = "proj", + }; + CBMImport imp = { + .local_name = "Header", + .module_path = "fastapi.Header", + }; + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, "docs_src/app/main.py", + "proj.docs_src.app.main.__file__", &imp, NULL); + + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(target->qualified_name, "proj.fastapi.param_functions.Header"); + + CBMImport owner_imp = { + .local_name = "Header", + .module_path = "fastapi", + }; + target = cbm_pipeline_resolve_import_node(&ctx, "docs_src/app/main.py", + "proj.docs_src.app.main.__file__", &owner_imp, NULL); + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(target->qualified_name, "proj.fastapi.param_functions.Header"); + + cbm_pipeline_set_pkgmap(NULL); + cbm_pkgmap_free(pkgmap); + cbm_gbuf_free(gb); + PASS(); +} + +TEST(import_symbol_fallback_prefers_import_path_over_insertion_order) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + + int64_t security_http_base = cbm_gbuf_upsert_node( + gb, "Class", "HTTPBase", "proj.fastapi.security.http.HTTPBase", + "fastapi/security/http.py", 1, 1, "{}"); + int64_t openapi_http_base = cbm_gbuf_upsert_node( + gb, "Class", "HTTPBase", "proj.fastapi.openapi.models.HTTPBase", + "fastapi/openapi/models.py", 1, 1, "{}"); + int64_t openapi_oauth2 = cbm_gbuf_upsert_node( + gb, "Class", "OAuth2", "proj.fastapi.openapi.models.OAuth2", + "fastapi/openapi/models.py", 1, 1, "{}"); + int64_t security_oauth2 = cbm_gbuf_upsert_node( + gb, "Class", "OAuth2", "proj.fastapi.security.oauth2.OAuth2", + "fastapi/security/oauth2.py", 1, 1, "{}"); + ASSERT_GT(security_http_base, 0); + ASSERT_GT(openapi_http_base, 0); + ASSERT_GT(openapi_oauth2, 0); + ASSERT_GT(security_oauth2, 0); + + cbm_pipeline_ctx_t ctx = { + .gbuf = gb, + .project_name = "proj", + }; + CBMImport model_alias = { + .local_name = "HTTPBaseModel", + .module_path = "fastapi.openapi.models.HTTPBase", + }; + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, "fastapi/security/http.py", + "proj.fastapi.security.http.__file__", &model_alias, + NULL); + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(target->qualified_name, "proj.fastapi.openapi.models.HTTPBase"); + + CBMImport public_class = { + .local_name = "HTTPBase", + .module_path = "fastapi.security.http.HTTPBase", + }; + target = cbm_pipeline_resolve_import_node(&ctx, "tests/test_security_http_base.py", + "proj.tests.test_security_http_base.__file__", + &public_class, NULL); + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(target->qualified_name, "proj.fastapi.security.http.HTTPBase"); + + CBMImport oauth_model_alias = { + .local_name = "OAuth2Model", + .module_path = "fastapi.openapi.models.OAuth2", + }; + target = cbm_pipeline_resolve_import_node(&ctx, "fastapi/security/oauth2.py", + "proj.fastapi.security.oauth2.__file__", + &oauth_model_alias, NULL); + ASSERT_NOT_NULL(target); + ASSERT_STR_EQ(target->qualified_name, "proj.fastapi.openapi.models.OAuth2"); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(import_resolution_long_header_prefers_source_relative_file) { + char *directory = pkgmap_long_test_value("include/", 'h', PKGMAP_LONG_TEST_FILL); + char *source_rel = directory ? cbm_pkgmap_join_path(directory, "main.c") : NULL; + char *target_rel = directory ? cbm_pkgmap_join_path(directory, "config.h") : NULL; + if (!directory || !source_rel || !target_rel) { + free(target_rel); + free(source_rel); + free(directory); + FAIL("long header fixture allocation"); + } + + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + if (!gb) { + free(target_rel); + free(source_rel); + free(directory); + FAIL("long header graph allocation"); + } + int64_t wrong = cbm_gbuf_upsert_node(gb, "File", "config.h", "proj.other.config.__file__", + "other/config.h", 1, 1, "{}"); + int64_t expected = cbm_gbuf_upsert_node(gb, "File", "config.h", "proj.deep.config.__file__", + target_rel, 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.gbuf = gb, .project_name = "proj"}; + CBMImport imp = {.local_name = "config.h", .module_path = "config.h"}; + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, source_rel, "proj.deep.main.__file__", &imp, NULL); + bool exact = wrong > 0 && expected > 0 && target && target->id == expected; + + cbm_gbuf_free(gb); + free(target_rel); + free(source_rel); + free(directory); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(import_resolution_long_sibling_path_is_exact) { + char *directory = pkgmap_long_test_value("styles/", 's', PKGMAP_LONG_TEST_FILL); + char *source_rel = directory ? cbm_pkgmap_join_path(directory, "main.scss") : NULL; + char *target_rel = directory ? cbm_pkgmap_join_path(directory, "_vars.scss") : NULL; + char *target_qn = target_rel ? cbm_pipeline_fqn_module("proj", target_rel) : NULL; + if (!directory || !source_rel || !target_rel || !target_qn) { + free(target_qn); + free(target_rel); + free(source_rel); + free(directory); + FAIL("long sibling fixture allocation"); + } + + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + if (!gb) { + free(target_qn); + free(target_rel); + free(source_rel); + free(directory); + FAIL("long sibling graph allocation"); + } + int64_t expected = + cbm_gbuf_upsert_node(gb, "Module", "_vars", target_qn, target_rel, 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.gbuf = gb, .project_name = "proj"}; + CBMImport imp = {.local_name = "vars", .module_path = "vars"}; + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + &ctx, source_rel, "proj.recipes.main.__file__", &imp, NULL); + bool exact = expected > 0 && target && target->id == expected; + + cbm_gbuf_free(gb); + free(target_qn); + free(target_rel); + free(source_rel); + free(directory); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(import_resolution_long_namespace_key_and_qn_are_exact) { + char *namespace_key = pkgmap_long_test_value("org.", 'n', PKGMAP_LONG_TEST_FILL); + char *target_qn = pkgmap_long_test_value("proj.", 'q', PKGMAP_LONG_TEST_FILL); + if (!namespace_key || !target_qn) { + free(target_qn); + free(namespace_key); + FAIL("long namespace fixture allocation"); + } + size_t module_size = strlen(namespace_key) + sizeof(".*"); + char *module_path = malloc(module_size); + if (!module_path) { + free(target_qn); + free(namespace_key); + FAIL("long namespace import allocation"); + } + snprintf(module_path, module_size, "%s.*", namespace_key); + + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + CBMHashTable *namespace_map = cbm_ht_create(CBM_SZ_16); + if (!gb || !namespace_map) { + cbm_ht_free(namespace_map); + cbm_gbuf_free(gb); + free(module_path); + free(target_qn); + free(namespace_key); + FAIL("long namespace graph or map allocation"); + } + int64_t expected = + cbm_gbuf_upsert_node(gb, "File", "namespace.py", target_qn, "namespace.py", 1, 1, "{}"); + cbm_ht_set(namespace_map, namespace_key, target_qn); + cbm_pipeline_ctx_t ctx = {.gbuf = gb, .project_name = "proj"}; + CBMImport imp = {.local_name = "*", .module_path = module_path}; + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + &ctx, "consumer.py", "proj.consumer.__file__", &imp, namespace_map); + bool exact = expected > 0 && target && target->id == expected; + + cbm_ht_free(namespace_map); + cbm_gbuf_free(gb); + free(module_path); + free(target_qn); + free(namespace_key); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(import_resolution_namespace_map_normalizes_declaration_and_import) { + CBMFileResult result = {0}; + result.namespace_name = "Acme::Tools"; + CBMFileResult *results[] = {&result}; + const char *rels[] = {"src/tools.php"}; + CBMHashTable *namespace_map = cbm_pipeline_namespace_map_build("proj", results, rels, SKIP_ONE); + char *target_qn = cbm_pipeline_fqn_compute("proj", rels[0], "__file__"); + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + if (!namespace_map || !target_qn || !gb) { + cbm_pipeline_namespace_map_free(namespace_map); + free(target_qn); + cbm_gbuf_free(gb); + FAIL("namespace normalization fixture allocation"); + } + + int64_t expected = + cbm_gbuf_upsert_node(gb, "File", "tools.php", target_qn, rels[0], 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.gbuf = gb, .project_name = "proj"}; + CBMImport imp = {.local_name = "*", .module_path = "Acme\\Tools\\*"}; + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + &ctx, "src/consumer.php", "proj.src.consumer.__file__", &imp, namespace_map); + bool exact = expected > 0 && target && target->id == expected; + + cbm_pipeline_namespace_map_free(namespace_map); + free(target_qn); + cbm_gbuf_free(gb); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(import_resolution_symbol_fallback_has_no_segment_limit) { + static const char module_path[] = "rootcandidate.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t"; + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + int64_t expected = cbm_gbuf_upsert_node( + gb, "Class", "rootcandidate", "proj.unrelated.RootCandidate", "target.py", 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.gbuf = gb, .project_name = "proj"}; + CBMImport imp = {.local_name = "alias", .module_path = module_path}; + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, "consumer.py", "proj.consumer.__file__", &imp, NULL); + bool exact = expected > 0 && target && target->id == expected; + + cbm_gbuf_free(gb); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(import_resolution_long_symbol_name_is_exact) { + char *symbol = pkgmap_long_test_value("Symbol", 'x', PKGMAP_LONG_TEST_FILL); + if (!symbol) { + FAIL("long symbol fixture allocation"); + } + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + if (!gb) { + free(symbol); + FAIL("long symbol graph allocation"); + } + int64_t expected = cbm_gbuf_upsert_node(gb, "Class", symbol, "proj.unrelated.LongSymbol", + "target.py", 1, 1, "{}"); + cbm_pipeline_ctx_t ctx = {.gbuf = gb, .project_name = "proj"}; + CBMImport imp = {.local_name = "alias", .module_path = symbol}; + const cbm_gbuf_node_t *target = + cbm_pipeline_resolve_import_node(&ctx, "consumer.py", "proj.consumer.__file__", &imp, NULL); + bool exact = expected > 0 && target && target->id == expected; + + cbm_gbuf_free(gb); + free(symbol); + ASSERT_TRUE(exact); + PASS(); +} + +/* DLL resolve test removed — feature removed due to Windows Defender + * false positive (Wacatac.B!ml). See issue #89. */ + +/* ═══════════════════════════════════════════════════════════════════ + * Incremental reindex + * ═══════════════════════════════════════════════════════════════════ */ + +typedef struct { + const char *key; + char value[CBM_SZ_64]; + bool had_value; +} pipeline_env_snapshot_t; + +static const char pipeline_test_env_enabled[] = "1"; + +static pipeline_env_snapshot_t pipeline_env_save(const char *key) { + pipeline_env_snapshot_t snap = {.key = key}; + snap.had_value = cbm_safe_getenv(key, snap.value, sizeof(snap.value), NULL) != NULL; + return snap; +} + +static void pipeline_env_restore(const pipeline_env_snapshot_t *snap) { + if (!snap || !snap->key) { + return; + } + if (snap->had_value) { + cbm_setenv(snap->key, snap->value, 1); + } else { + cbm_unsetenv(snap->key); + } +} + +static int run_parallel_incremental_phase_failure_case(const char *phase) { + pipeline_env_snapshot_t fail_env = pipeline_env_save(CBM_TEST_FAIL_INCREMENTAL_PHASE); + + if (setup_incremental_parallel_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_TRUE(cbm_pipeline_graph_changed(p)); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_before = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_before, 0); + cbm_store_close(s); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); + ASSERT_EQ(rewrite_incremental_parallel_repo(), 0); - cbm_shell_result_t r; - ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); - ASSERT_STR_EQ(r.shebang, "/bin/bash"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "ENVIRONMENT"), "development"); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "YOUR_CONTAINER_NAME"), - "order-email-extractor-endpoint"); + cbm_file_info_t *files = NULL; + int file_count = 0; + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL, .ignore_file = NULL, .max_file_size = 0}; + ASSERT_EQ(cbm_discover(g_incr_tmpdir, &opts, &files, &file_count), 0); + ASSERT_GTE(file_count, INCR_PARALLEL_CHANGED_FILE_COUNT); - ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker build")); - ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker run")); - ASSERT(str_array_256_contains(r.docker_cmds, r.docker_cmd_count, "docker-compose up")); + cbm_setenv(CBM_TEST_FAIL_INCREMENTAL_PHASE, phase, 1); + int rc = cbm_pipeline_run_incremental(p, g_incr_dbpath, files, file_count); + pipeline_env_restore(&fail_env); + cbm_discover_free(files, file_count); + + ASSERT_NEQ(rc, 0); + s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_count_nodes(s, project), nodes_before); + cbm_store_close(s); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); + + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + return 0; +} + +TEST(incremental_full_then_noop) { + /* Full index, then re-run → should detect no changes and skip */ + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + /* First: full index */ + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + + /* Verify nodes exist */ + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_before = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_before, 0); + cbm_store_close(s); + + /* Second: incremental — nothing changed → should be no-op */ + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_FALSE(cbm_pipeline_graph_changed(p)); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_NOOP); + cbm_pipeline_free(p); + + s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_after = cbm_store_count_nodes(s, project); + /* Node count should be same (no duplicates, no loss) */ + ASSERT_EQ(nodes_after, nodes_before); + cbm_store_close(s); + free(project); + cbm_config_close(cfg); + + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_aborts_when_previous_coverage_is_unreadable) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + ASSERT_NOT_NULL(project); + cbm_pipeline_free(p); + + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_before = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_before, 0); + /* Simulate an unreadable prior coverage generation while leaving the + * graph and file hashes healthy enough to otherwise run incrementally. */ + ASSERT_EQ( + cbm_store_exec(s, "ALTER TABLE index_coverage RENAME COLUMN detail TO broken_detail;"), + CBM_STORE_OK); + cbm_store_close(s); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + FILE *f = cbm_fopen(path, "a"); + ASSERT_NOT_NULL(f); + ASSERT_GT(fprintf(f, "\nfunc MustNotBeIndexed() int { return 7 }\n"), 0); + ASSERT_EQ(fclose(f), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_TRUE(cbm_pipeline_run(p) != 0); + cbm_pipeline_free(p); + cbm_config_close(cfg); + + /* Failure happens before the dump/replacement boundary, preserving the + * original graph rather than publishing a falsely complete generation. */ + s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_count_nodes(s, project), nodes_before); + cbm_store_close(s); + free(project); + + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_touch_only_refreshes_metadata_without_reindex) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + int64_t generation_before = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &generation_before), + CBM_STORE_OK); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + struct stat before; + ASSERT_EQ(stat(path, &before), 0); + ASSERT_EQ(pipeline_bump_file_mtime_seconds(path, &before, PIPELINE_TEST_MTIME_BUMP_SECONDS), + 0); + struct stat touched; + ASSERT_EQ(stat(path, &touched), 0); + ASSERT_NEQ(cbm_stat_mtime_ns(&touched), cbm_stat_mtime_ns(&before)); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + int64_t generation_after = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &generation_after), + CBM_STORE_OK); + ASSERT_EQ(generation_after, generation_before); + + int64_t hash_mtime_ns = 0; + ASSERT_EQ(pipeline_store_file_hash_mtime(g_incr_dbpath, project, "helper.go", + &hash_mtime_ns), + CBM_STORE_OK); + ASSERT_EQ(hash_mtime_ns, cbm_stat_mtime_ns(&touched)); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_detects_changed_file) { + /* Full index, modify one file, re-index → changed file re-parsed */ + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + /* First: full index */ + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + + /* Modify helper.go — add a new function */ + char path[512]; + snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\n" + "func Helper() string {\n\treturn \"hello\"\n}\n\n" + "func NewFunc() int {\n\treturn 42\n}\n"); + fclose(f); + + /* Second: incremental — should detect change and re-index */ + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + /* Verify node count increased (NewFunc was added) */ + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_after = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_after, 0); + cbm_store_close(s); + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_fast_exact_upsert_matches_full_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 1\n}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_FALSE(cbm_pipeline_incremental_fallback(p)); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func NewLeaf() int {\n\treturn 7\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.done files=1") != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); + + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewLeaf")); + int64_t generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "leaf.go", + &generation), + CBM_STORE_OK); + ASSERT_GT(generation, CBM_PIPELINE_COMPAT_GENERATION); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 0); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + printf(" [exact-upsert-diff] %s\n", diff_err); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_fast_body_only_change_uses_graph_noop) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + int64_t generation_before = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &generation_before), + CBM_STORE_OK); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Helper() string {\n\treturn \"goodbye\"\n}\n"), + 0); + + cbm_store_t *store = cbm_store_open_path_query(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_file_info_t changed[] = { + {.path = path, .rel_path = "helper.go", .language = CBM_LANG_GO}, + }; + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files(store, g_incr_tmpdir, project, + changed, CBM_ALLOC_ONE, &scratch, + &delta), + CBM_STORE_OK); + const cbm_store_file_delta_t *store_delta = &delta.delta; + bool graph_equal = false; + ASSERT_EQ(cbm_store_file_delta_batch_graph_equal(store, &store_delta, CBM_ALLOC_ONE, + &graph_equal), + CBM_STORE_OK); + if (!graph_equal) { + static const char node_owner_count_sql[] = + "SELECT COUNT(*) FROM node_owners WHERE project = ?1 AND rel_path = ?2;"; + static const char edge_owner_count_sql[] = + "SELECT COUNT(*) FROM edge_owners WHERE project = ?1 AND rel_path = ?2;"; + static const char export_count_sql[] = + "SELECT COUNT(*) FROM symbol_exports WHERE project = ?1 AND rel_path = ?2;"; + static const char import_count_sql[] = + "SELECT COUNT(*) FROM import_refs WHERE project = ?1 AND rel_path = ?2;"; + int stored_nodes = -1; + int stored_edges = -1; + int stored_exports = -1; + int stored_imports = -1; + (void)pipeline_store_count_file_rows_sql(g_incr_dbpath, project, "helper.go", + node_owner_count_sql, &stored_nodes); + (void)pipeline_store_count_file_rows_sql(g_incr_dbpath, project, "helper.go", + edge_owner_count_sql, &stored_edges); + (void)pipeline_store_count_file_rows_sql(g_incr_dbpath, project, "helper.go", + export_count_sql, &stored_exports); + (void)pipeline_store_count_file_rows_sql(g_incr_dbpath, project, "helper.go", + import_count_sql, &stored_imports); + char detail[CBM_SZ_512]; + int dn = snprintf(detail, sizeof(detail), + "graph equality rejected body-only delta: stored n/e/x/i=%d/%d/%d/%d " + "delta n/e/x/i=%d/%d/%d/%d ctx n/e=%d/%d", + stored_nodes, stored_edges, stored_exports, stored_imports, + delta.delta.node_count, delta.delta.edge_count, + delta.delta.export_count, delta.delta.import_count, + delta.delta.context_node_count, delta.delta.context_edge_count); + if (dn < 0 || (size_t)dn >= sizeof(detail)) { + FAIL("graph equality rejected body-only delta; diagnostic overflow"); + } + FAIL(detail); + } + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + cbm_store_close(store); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + if (!strstr(logs, "msg=incremental.exact.frontier changed=1 expanded=2") || + !strstr(logs, "msg=incremental.exact.noop files=2")) { + const char *debug = strstr(logs, "msg=delta.graph_equal.mismatch"); + char detail[CBM_SZ_512]; + int dn = snprintf(detail, sizeof(detail), "missing incremental no-op marker: %.420s", + debug ? debug : logs); + if (dn < 0 || (size_t)dn >= sizeof(detail)) { + FAIL("missing incremental no-op marker; diagnostic overflow"); + } + FAIL(detail); + } + ASSERT_FALSE(cbm_pipeline_graph_changed(p)); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_NOOP); + cbm_pipeline_free(p); + + int64_t generation_after = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &generation_after), + CBM_STORE_OK); + ASSERT_GT(generation_after, generation_before); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "body-only graph no-op differed from fresh FAST rebuild"); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_fast_two_file_batch_exact_upsert_matches_full_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func main() {\n\tHelper()\n\tNewHelper()\n}\n\n" + "func NewMain() int {\n\treturn 11\n}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Helper() string {\n\treturn \"updated\"\n}\n\n" + "func NewHelper() int {\n\treturn 13\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=2") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.done files=2") != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); + + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewMain")); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewHelper")); + int64_t main_generation = 0; + int64_t helper_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "main.go", + &main_generation), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &helper_generation), + CBM_STORE_OK); + ASSERT_GT(main_generation, CBM_PIPELINE_COMPAT_GENERATION); + ASSERT_EQ(main_generation, helper_generation); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "two-file exact upsert differed from fresh FAST rebuild"); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_fast_configured_cap_uses_containment_for_oversized_inbound_frontier) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + ASSERT_EQ(write_incremental_frontier_fixture(CBM_ALLOC_ONE), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, "1"), 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + ASSERT_EQ(write_incremental_leaf_file(CBM_SZ_2), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.fallback reason=frontier_too_large") != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_CONTAINMENT); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "frontier_too_large"); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_GT(stats.affected_paths, 1); + ASSERT_EQ(stats.affected_paths_limit, cbm_pipeline_exact_max_affected_paths(p)); + ASSERT_TRUE(stats.affected_paths_truncated); + ASSERT_EQ(stats.published_paths, -1); + cbm_pipeline_free(p); + + cbm_store_t *owner_store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(owner_store); + int node_owners = 0; + int edge_owners = 0; + ASSERT_EQ(cbm_store_count_file_delta_owners(owner_store, project, "leaf.go", &node_owners, + &edge_owners), + CBM_STORE_OK); + ASSERT_GT(node_owners, 0); + cbm_store_close(owner_store); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "oversized inbound fallback differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(infra_parse_shell_with_source) { - /* Port of TestParseShellScriptWithSource */ - const char *src = "#!/usr/bin/env bash\n" - "source ./config.sh\n" - ". /etc/profile.d/env.sh\n"; +TEST(incremental_fast_c_header_frontier_too_large_uses_full_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_shell_result_t r; - ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); - ASSERT_STR_EQ(r.shebang, "/usr/bin/env bash"); - ASSERT(str_array_256_contains(r.sources, r.source_count, "./config.sh")); - ASSERT(str_array_256_contains(r.sources, r.source_count, "/etc/profile.d/env.sh")); - PASS(); -} + char skipped_dir[CBM_PATH_MAX]; + int n = snprintf(skipped_dir, sizeof(skipped_dir), "%s/scripts", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(skipped_dir)); + ASSERT_TRUE(cbm_mkdir_p(skipped_dir, 0755)); + char skipped_path[CBM_PATH_MAX]; + n = snprintf(skipped_path, sizeof(skipped_path), "%s/scripts/probe.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(skipped_path)); + ASSERT_EQ(th_write_file(skipped_path, "def skipped_probe():\n return 1\n"), 0); + + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + char conservative_cap[CBM_SZ_32]; + n = snprintf(conservative_cap, sizeof(conservative_cap), "%d", CBM_SZ_4); + ASSERT(n >= 0 && (size_t)n < sizeof(conservative_cap)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, + conservative_cap), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -TEST(infra_parse_shell_secret_filtered) { - /* Port of TestParseShellScriptSecretFiltered */ - const char *src = "#!/bin/bash\n" - "export API_SECRET=\"should-not-appear\"\n" - "export DATABASE_URL=\"https://db.example.com\"\n"; + ASSERT_EQ(write_incremental_c_header_extra_export(CBM_SZ_16), 0); - cbm_shell_result_t r; - ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); - ASSERT(find_env_var(r.env_vars, r.env_count, "API_SECRET") == NULL); - ASSERT_STR_EQ(find_env_var(r.env_vars, r.env_count, "DATABASE_URL"), "https://db.example.com"); - PASS(); -} + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.fallback reason=frontier_too_large") != NULL); + char fallback_log[CBM_SZ_128]; + n = snprintf(fallback_log, sizeof(fallback_log), + "msg=incremental.fallback reason=%s scope=%s", + CBM_PIPELINE_DELTA_REASON_FRONTIER_TOO_LARGE, + CBM_PIPELINE_DELTA_SCOPE_C_FAMILY_HEADER); + ASSERT(n >= 0 && (size_t)n < sizeof(fallback_log)); + ASSERT(strstr(logs, fallback_log) != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "frontier_too_large"); + ASSERT_TRUE(cbm_pipeline_incremental_fallback(p)); + cbm_pipeline_free(p); -TEST(infra_parse_shell_shebang_only) { - /* Port of TestParseShellScriptShebanOnly */ - const char *src = "#!/bin/bash\n# just comments\n"; - cbm_shell_result_t r; - ASSERT_EQ(cbm_parse_shell_source(src, &r), 0); - ASSERT_STR_EQ(r.shebang, "/bin/bash"); - PASS(); -} + int skipped_nodes = 0; + ASSERT_EQ(pipeline_store_count_file_rows_sql( + g_incr_dbpath, project, "scripts/probe.py", + "SELECT COUNT(*) FROM nodes WHERE project = ?1 AND file_path = ?2;", + &skipped_nodes), + CBM_STORE_OK); + ASSERT_EQ(skipped_nodes, 0); + int skipped_hashes = 0; + ASSERT_EQ(pipeline_store_count_file_rows_sql( + g_incr_dbpath, project, "scripts/probe.py", + "SELECT COUNT(*) FROM file_hashes WHERE project = ?1 AND rel_path = ?2;", + &skipped_hashes), + CBM_STORE_OK); + ASSERT_EQ(skipped_hashes, 0); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 0); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "C header full fallback differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); -TEST(infra_parse_shell_truly_empty) { - /* Port of TestParseShellScriptTrulyEmpty */ - cbm_shell_result_t r; - ASSERT_EQ(cbm_parse_shell_source("# no shebang, just comments\n", &r), -1); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── Infrascan: Terraform parser ────────────────────────────────── */ +TEST(incremental_fast_default_c_header_frontier_cap_allows_bounded_exact) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -TEST(infra_parse_terraform_full) { - /* Port of TestParseTerraformFile */ - const char *src = "\n" - "terraform {\n" - " required_providers {\n" - " google = {\n" - " source = \"hashicorp/google\"\n" - " version = \"~> 6.35.0\"\n" - " }\n" - " }\n" - " backend \"gcs\" {\n" - " bucket = \"example-tf\"\n" - " prefix = \"state\"\n" - " }\n" - "}\n" - "\n" - "variable \"project_id\" {\n" - " description = \"The GCP project ID\"\n" - " type = string\n" - " default = \"example-cloud\"\n" - "}\n" - "\n" - "variable \"region\" {\n" - " description = \"The region\"\n" - " type = string\n" - "}\n" - "\n" - "resource \"google_cloud_run_service\" \"main\" {\n" - " name = \"my-service\"\n" - " location = var.region\n" - "}\n" - "\n" - "resource \"google_compute_address\" \"nat_ip\" {\n" - " name = \"nat-ip\"\n" - " region = var.region\n" - "}\n" - "\n" - "output \"service_url\" {\n" - " value = google_cloud_run_service.main.status[0].url\n" - "}\n" - "\n" - "data \"google_project\" \"project\" {\n" - "}\n" - "\n" - "module \"vpc\" {\n" - " source = \"./modules/vpc\"\n" - "}\n" - "\n" - "locals {\n" - " env = \"prod\"\n" - "}\n"; + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); - cbm_terraform_result_t r; - ASSERT_EQ(cbm_parse_terraform_source(src, &r), 0); - ASSERT_STR_EQ(r.backend, "gcs"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* Resources */ - ASSERT_EQ(r.resource_count, 2); - bool found_cloud_run = false; - for (int i = 0; i < r.resource_count; i++) { - if (strcmp(r.resources[i].type, "google_cloud_run_service") == 0 && - strcmp(r.resources[i].name, "main") == 0) { - found_cloud_run = true; - } + ASSERT_EQ(write_incremental_c_header_extra_export(CBM_SZ_16), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.frontier changed=1 expanded=") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.fallback") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT_NULL(cbm_pipeline_publish_reason(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, CBM_ALLOC_ONE); + ASSERT_GT(stats.affected_paths, CBM_SZ_4); + ASSERT(stats.affected_paths <= CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS); + ASSERT_EQ(stats.published_paths, stats.affected_paths); + cbm_pipeline_free(p); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err + : "default C header exact update differed from fresh rebuild"); } - ASSERT(found_cloud_run); + ASSERT_EQ(diff_rc, 0); - /* Variables */ - ASSERT_EQ(r.variable_count, 2); - bool found_project_id = false; - for (int i = 0; i < r.variable_count; i++) { - if (strcmp(r.variables[i].name, "project_id") == 0) { - ASSERT_STR_EQ(r.variables[i].default_val, "example-cloud"); - ASSERT_STR_EQ(r.variables[i].type, "string"); - ASSERT_STR_EQ(r.variables[i].description, "The GCP project ID"); - found_project_id = true; - } + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_fast_c_source_frontier_too_large_uses_full_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - ASSERT(found_project_id); - /* Outputs */ - ASSERT_EQ(r.output_count, 1); - ASSERT_STR_EQ(r.outputs[0], "service_url"); + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); + ASSERT_EQ(write_incremental_c_header_second_level_callers(), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + char conservative_cap[CBM_SZ_32]; + int n = snprintf(conservative_cap, sizeof(conservative_cap), "%d", CBM_SZ_4); + ASSERT(n >= 0 && (size_t)n < sizeof(conservative_cap)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, + conservative_cap), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* Data sources */ - ASSERT_EQ(r.data_source_count, 1); - ASSERT_STR_EQ(r.data_sources[0].type, "google_project"); - ASSERT_STR_EQ(r.data_sources[0].name, "project"); + ASSERT_EQ(write_incremental_c_source_extra_call(CBM_SZ_16), 0); - /* Modules */ - ASSERT_EQ(r.module_count, 1); - ASSERT_STR_EQ(r.modules[0].tf_name, "vpc"); - ASSERT_STR_EQ(r.modules[0].source, "./modules/vpc"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.fallback reason=frontier_too_large") != NULL); + char fallback_log[CBM_SZ_128]; + n = snprintf(fallback_log, sizeof(fallback_log), + "msg=incremental.fallback reason=%s scope=%s", + CBM_PIPELINE_DELTA_REASON_FRONTIER_TOO_LARGE, + CBM_PIPELINE_DELTA_SCOPE_C_FAMILY_SOURCE); + ASSERT(n >= 0 && (size_t)n < sizeof(fallback_log)); + ASSERT(strstr(logs, fallback_log) != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "frontier_too_large"); + cbm_pipeline_free(p); - /* Locals */ - ASSERT(r.has_locals); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "C source full fallback differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(infra_parse_terraform_variables_only) { - /* Port of TestParseTerraformVariablesOnly — secret default filtered */ - const char *src = "\n" - "variable \"project_id\" {\n" - " description = \"The GCP project ID\"\n" - " type = string\n" - " default = \"example-cloud\"\n" - "}\n" - "\n" - "variable \"secret_key\" {\n" - " description = \"A secret\"\n" - " type = string\n" - " default = \"sk-1234567890abcdef12345\"\n" - "}\n"; +TEST(incremental_fast_default_c_source_frontier_cap_allows_bounded_exact) { + enum { PIPELINE_C_SOURCE_EXACT_MAX_AFFECTED = CBM_SZ_32 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_terraform_result_t r; - ASSERT_EQ(cbm_parse_terraform_source(src, &r), 0); - ASSERT_EQ(r.variable_count, 2); + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); + ASSERT_EQ(write_incremental_c_header_second_level_callers(), 0); - /* secret_key default should be filtered */ - for (int i = 0; i < r.variable_count; i++) { - if (strcmp(r.variables[i].name, "secret_key") == 0) { - ASSERT_STR_EQ(r.variables[i].default_val, ""); - } + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + ASSERT_EQ(write_incremental_c_source_extra_call(CBM_SZ_16), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.frontier changed=1 expanded=") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.fallback") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT_NULL(cbm_pipeline_publish_reason(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, CBM_ALLOC_ONE); + ASSERT_GT(stats.affected_paths, CBM_SZ_4); + ASSERT(stats.affected_paths <= CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS); + ASSERT_EQ(PIPELINE_C_SOURCE_EXACT_MAX_AFFECTED, + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS); + ASSERT_EQ(stats.published_paths, stats.affected_paths); + cbm_pipeline_free(p); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err + : "configured C source exact update differed from fresh rebuild"); } - PASS(); -} + ASSERT_EQ(diff_rc, 0); -TEST(infra_parse_terraform_empty) { - /* Port of TestParseTerraformEmpty */ - cbm_terraform_result_t r; - ASSERT_EQ(cbm_parse_terraform_source("# just comments\n", &r), -1); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── Helm Chart.yaml dependency parsing (#338) ──────────────────── */ +TEST(incremental_overlay_publish_single_c_header_uses_active_overlay) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -TEST(helm_parse_chart_dependencies_issue338) { - const char *src = "apiVersion: v2\n" - "name: mychart\n" - "version: 1.0.0\n" - "dependencies:\n" - " - name: postgresql\n" - " repository: https://charts.bitnami.com/bitnami\n" - " version: 12.x.x\n" - " - name: redis\n" - " repository: https://charts.bitnami.com/bitnami\n" - "maintainers:\n" - " - name: alice\n"; /* not a dependency — outside the block */ - cbm_helm_chart_t hc; - ASSERT_EQ(cbm_parse_helm_chart(src, &hc), 0); - ASSERT_STR_EQ(hc.chart_name, "mychart"); - ASSERT_EQ(hc.dep_count, 2); - ASSERT_STR_EQ(hc.deps[0], "postgresql"); - ASSERT_STR_EQ(hc.deps[1], "redis"); - PASS(); -} + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); -TEST(helm_parse_chart_no_deps_issue338) { - cbm_helm_chart_t hc; - ASSERT_EQ(cbm_parse_helm_chart("name: solo\nversion: 0.1.0\n", &hc), 0); - ASSERT_STR_EQ(hc.chart_name, "solo"); - ASSERT_EQ(hc.dep_count, 0); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + ASSERT_EQ(write_incremental_c_header_extra_export(CBM_SZ_16), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=1") != NULL); + ASSERT(strstr(logs, "scope=c_family_header") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + ASSERT(!cbm_pipeline_graph_changed(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, 1); + ASSERT_EQ(stats.published_paths, 1); + cbm_pipeline_free(p); + + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "shared_extra")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "shared.h", + "shared_extra")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── Infrascan: infra QN helper ─────────────────────────────────── */ +TEST(incremental_overlay_single_c_header_type_impl_pair_keeps_canonical_rows_visible) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -/* ── Function Registry / Resolver tests ─────────────────────────── */ + char header_path[CBM_PATH_MAX]; + char source_path[CBM_PATH_MAX]; + int n = snprintf(header_path, sizeof(header_path), "%s/paired.h", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(header_path)); + n = snprintf(source_path, sizeof(source_path), "%s/paired.c", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(source_path)); + ASSERT_EQ(th_write_file(header_path, + "#ifndef PAIRED_H\n" + "#define PAIRED_H\n" + "typedef struct Paired Paired;\n" + "int paired_value(Paired *p);\n" + "#endif\n"), + 0); + ASSERT_EQ(th_write_file(source_path, + "#include \"paired.h\"\n\n" + "struct Paired {\n" + " int value;\n" + "};\n\n" + "int paired_value(Paired *p) {\n" + " return p ? p->value : 0;\n" + "}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -TEST(registry_resolve_single_candidate) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); - cbm_registry_add(reg, "ValidateOrder", "svcB.validators.ValidateOrder", "Function"); + ASSERT_EQ(th_write_file(header_path, + "#ifndef PAIRED_H\n" + "#define PAIRED_H\n" + "typedef struct Paired Paired;\n" + "int paired_value(Paired *p);\n" + "static int paired_extra(void) {\n" + " return 7;\n" + "}\n" + "#endif\n"), + 0); - /* Normal resolve unique name */ - cbm_resolution_t r = cbm_registry_resolve(reg, "CreateOrder", "svcC.caller", NULL, NULL, 0); - ASSERT_STR_EQ(r.qualified_name, "svcA.handlers.CreateOrder"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=1") != NULL); + ASSERT(strstr(logs, CBM_PIPELINE_DELTA_REASON_HEADER_TYPE_IMPL_PAIR) == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + cbm_pipeline_free(p); - /* Fuzzy resolve with unknown prefix */ - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "unknownPkg.CreateOrder", "svcC.caller", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT_STR_EQ(fr.result.qualified_name, "svcA.handlers.CreateOrder"); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "paired_value")); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "paired_extra")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "paired.h", + "paired_extra")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); - cbm_registry_free(reg); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_fuzzy_nonexistent) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); - - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "NonExistent", "svcC.caller", NULL, NULL, 0); - ASSERT_FALSE(fr.ok); +TEST(incremental_c_header_batch_uses_additive_overlay_when_owned_rows_preserved) { + enum { + PIPELINE_C_HEADER_BATCH_CHANGED = CBM_SZ_2, + PIPELINE_C_HEADER_BATCH_AFFECTED_CAP = CBM_SZ_8, + }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_registry_free(reg); - PASS(); -} + ASSERT_EQ(write_incremental_two_header_additive_fixture(CBM_ALLOC_ONE, CBM_SZ_2, false), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + char cap_value[CBM_SZ_32]; + int n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_C_HEADER_BATCH_CHANGED); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_CHANGED_PATHS, cap_value), 0); + n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_C_HEADER_BATCH_AFFECTED_CAP); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), 0); + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -TEST(registry_fuzzy_multiple_best_by_distance) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "svcA.handlers.Process", "Function"); - cbm_registry_add(reg, "Process", "svcB.handlers.Process", "Function"); + ASSERT_EQ(write_incremental_two_header_additive_fixture(CBM_ALLOC_ONE, CBM_SZ_2, true), + 0); - /* Caller in svcA → prefer svcA */ - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "unknown.Process", "svcA.other", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT_STR_EQ(fr.result.qualified_name, "svcA.handlers.Process"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=2") != NULL); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=2") != NULL); + ASSERT(strstr(logs, CBM_PIPELINE_DELTA_REASON_ADDITIVE_SUBSET_REQUIRED) == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, PIPELINE_C_HEADER_BATCH_CHANGED); + ASSERT_EQ(stats.affected_paths, PIPELINE_C_HEADER_BATCH_CHANGED); + ASSERT_EQ(stats.published_paths, PIPELINE_C_HEADER_BATCH_CHANGED); + cbm_pipeline_free(p); - /* Caller in svcB → prefer svcB */ - fr = cbm_registry_fuzzy_resolve(reg, "unknown.Process", "svcB.other", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT_STR_EQ(fr.result.qualified_name, "svcB.handlers.Process"); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "alpha_added")); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "beta_added")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "alpha.h", + "alpha_added")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "beta.h", + "beta_added")); - cbm_registry_free(reg); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_fuzzy_simple_name_extraction) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "DoWork", "myproject.utils.DoWork", "Function"); +TEST(incremental_c_header_uses_exact_not_additive_overlay_without_subset_proof) { + enum { + PIPELINE_C_HEADER_OVERLAY_MAX_AFFECTED = CBM_SZ_16, + PIPELINE_C_HEADER_AFFECTED_FRONTIER = + CBM_SZ_2 + (PIPELINE_INCR_C_HEADER_IMPORTER_COUNT * CBM_SZ_2), + }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* Deeply qualified name → extract "DoWork" */ - cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "some.deep.module.DoWork", - "myproject.caller", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT_STR_EQ(fr.result.qualified_name, "myproject.utils.DoWork"); + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); + ASSERT_EQ(write_incremental_c_header_second_level_callers(), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + char cap_value[CBM_SZ_32]; + int n = snprintf(cap_value, sizeof(cap_value), "%d", + PIPELINE_C_HEADER_OVERLAY_MAX_AFFECTED); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), + 0); + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_registry_free(reg); - PASS(); -} + ASSERT_EQ(write_incremental_c_header_extra_export(CBM_SZ_16), 0); + ASSERT_EQ(write_incremental_c_header_impl_marker(CBM_SZ_2), 0); -TEST(registry_fuzzy_empty) { - cbm_registry_t *reg = cbm_registry_new(); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=2") != NULL); + char frontier_log[CBM_SZ_128]; + int log_n = snprintf(frontier_log, sizeof(frontier_log), + "msg=incremental.exact.frontier changed=2 expanded=%d", + PIPELINE_C_HEADER_AFFECTED_FRONTIER); + ASSERT(log_n >= 0 && (size_t)log_n < sizeof(frontier_log)); + ASSERT(strstr(logs, frontier_log) != NULL); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=") == NULL); + char done_log[CBM_SZ_128]; + log_n = snprintf(done_log, sizeof(done_log), "msg=incremental.exact.done files=%d", + PIPELINE_C_HEADER_AFFECTED_FRONTIER); + ASSERT(log_n >= 0 && (size_t)log_n < sizeof(done_log)); + ASSERT(strstr(logs, done_log) != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT(cbm_pipeline_graph_changed(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, CBM_SZ_2); + ASSERT_EQ(stats.affected_paths, PIPELINE_C_HEADER_AFFECTED_FRONTIER); + ASSERT_EQ(stats.published_paths, PIPELINE_C_HEADER_AFFECTED_FRONTIER); + cbm_pipeline_free(p); - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "SomeFunc", "myproject.caller", NULL, NULL, 0); - ASSERT_FALSE(fr.ok); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "C header exact update differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); - cbm_registry_free(reg); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_exists) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "pkg.module.Foo", "Function"); - cbm_registry_add(reg, "Bar", "pkg.module.Bar", "Method"); - - ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Foo")); - ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Bar")); - ASSERT_FALSE(cbm_registry_exists(reg, "pkg.module.Missing")); - ASSERT_FALSE(cbm_registry_exists(reg, "")); +TEST(incremental_fast_configured_frontier_cap_allows_bounded_exact) { + enum { + PIPELINE_EXPECTED_EXACT_FRONTIER_FILES = + PIPELINE_INCR_FRONTIER_CALLER_COUNT + CBM_ALLOC_ONE, + PIPELINE_CONFIGURED_AFFECTED_CAP = CBM_SZ_8, + }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_registry_free(reg); - PASS(); -} + ASSERT_EQ(write_incremental_frontier_fixture(CBM_ALLOC_ONE), 0); -TEST(registry_confidence_import_map) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + char cap_value[CBM_SZ_32]; + int n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_CONFIGURED_AFFECTED_CAP); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), 0); - const char *keys[] = {"other"}; - const char *vals[] = {"proj.other"}; - cbm_resolution_t r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1); - ASSERT_STR_EQ(r.qualified_name, "proj.other.Foo"); - ASSERT(r.confidence > 0.90 && r.confidence <= 1.0); - ASSERT_STR_EQ(r.strategy, "import_map"); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_registry_free(reg); - PASS(); -} + ASSERT_EQ(write_incremental_leaf_file_with_extra(CBM_SZ_2), 0); -TEST(registry_confidence_import_map_suffix) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.other.sub.Foo", "Function"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + char frontier_log[CBM_SZ_128]; + n = snprintf(frontier_log, sizeof(frontier_log), + "msg=incremental.exact.frontier changed=1 expanded=%d", + PIPELINE_EXPECTED_EXACT_FRONTIER_FILES); + ASSERT(n >= 0 && (size_t)n < sizeof(frontier_log)); + ASSERT(strstr(logs, frontier_log) != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT_NULL(cbm_pipeline_publish_reason(p)); + cbm_pipeline_free(p); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "LeafExtra")); - const char *keys[] = {"other"}; - const char *vals[] = {"proj.other"}; - cbm_resolution_t r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1); - ASSERT_STR_EQ(r.qualified_name, "proj.other.sub.Foo"); - ASSERT(r.confidence > 0.80 && r.confidence <= 0.90); - ASSERT_STR_EQ(r.strategy, "import_map_suffix"); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "configured exact frontier differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); - cbm_registry_free(reg); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_confidence_same_module) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); +TEST(incremental_full_defer_exact_delta_reindexes_defers_global_derived_refresh) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0); - ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo"); - ASSERT(r.confidence > 0.85 && r.confidence <= 0.95); - ASSERT_STR_EQ(r.strategy, "same_module"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFER_EXACT_DELTA_REINDEXES), + 0); - cbm_registry_free(reg); - PASS(); -} + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_TRUE(cbm_pipeline_incremental_derived_results_refresh_defers_exact_delta_reindexes(p)); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -TEST(registry_confidence_unique_name) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); + ASSERT_EQ(write_incremental_leaf_file_with_extra(CBM_SZ_2), 0); - cbm_resolution_t r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0); - ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar"); - ASSERT(r.confidence > 0.70 && r.confidence <= 0.80); - ASSERT_STR_EQ(r.strategy, "unique_name"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.done files=1") != NULL); + ASSERT(strstr(logs, "pass=incr_similarity") == NULL); + ASSERT(strstr(logs, "pass=incr_semantic_edges") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT_NULL(cbm_pipeline_publish_reason(p)); + cbm_pipeline_free(p); - cbm_registry_free(reg); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "LeafExtra")); + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, + CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_STATUS_STALE)); + cbm_store_close(s); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_confidence_suffix_match) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function"); - cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function"); +TEST(incremental_full_defer_exact_delta_reindexes_mixed_delete_upsert_marks_semantic_stale) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.svcA.caller", NULL, NULL, 0); - ASSERT_STR_EQ(r.qualified_name, "proj.svcA.Process"); - ASSERT(r.confidence > 0.50 && r.confidence <= 0.60); - ASSERT_STR_EQ(r.strategy, "suffix_match"); + ASSERT_EQ(write_incremental_leaf_file(CBM_ALLOC_ONE), 0); - cbm_registry_free(reg); - PASS(); -} + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFER_EXACT_DELTA_REINDEXES), + 0); -TEST(registry_fuzzy_confidence_single) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function"); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Leaf")); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(cbm_unlink(path), 0); + n = snprintf(path, sizeof(path), "%s/extra.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Extra() int {\n\treturn 7\n}\n"), + 0); - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "unknownPkg.Handler", "proj.caller", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT(fr.result.confidence > 0.35 && fr.result.confidence <= 0.45); - ASSERT_STR_EQ(fr.result.strategy, "fuzzy"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + ASSERT(strstr(logs, "deleted=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.done files=2") != NULL); + ASSERT(strstr(logs, "pass=incr_similarity") == NULL); + ASSERT(strstr(logs, "pass=incr_semantic_edges") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT_NULL(cbm_pipeline_publish_reason(p)); + cbm_pipeline_free(p); - cbm_registry_free(reg); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "Leaf")); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Extra")); + int64_t leaf_generation = 0; + int64_t extra_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "leaf.go", + &leaf_generation), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "extra.go", + &extra_generation), + CBM_STORE_OK); + + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, + CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_STATUS_STALE)); + cbm_store_close(s); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_fuzzy_confidence_distance) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function"); - cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function"); +TEST(incremental_full_defer_all_incremental_reindexes_defers_containment_semantic_refresh) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "unknownPkg.Process", "proj.svcA.other", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT(fr.result.confidence > 0.25 && fr.result.confidence <= 0.35); - ASSERT_STR_EQ(fr.result.strategy, "fuzzy"); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 1\n}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set( + cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFER_ALL_INCREMENTAL_REINDEXES), + 0); - cbm_registry_free(reg); - PASS(); -} + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -TEST(registry_negative_import_rejects) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "proj.billing.Process", "Function"); - cbm_registry_add(reg, "Process", "proj.handler.Process", "Function"); + n = snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func main() {\n\tHelper()\n\tNewHelper()\n\tLeaf()\n}\n\n" + "func NewMain() int {\n\treturn 11\n}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Helper() string {\n\treturn \"updated\"\n}\n\n" + "func NewHelper() int {\n\treturn 13\n}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func NewLeaf() int {\n\treturn 17\n}\n"), + 0); - /* Import only handler's module → should prefer handler */ - const char *keys[] = {"handler"}; - const char *vals[] = {"proj.handler"}; - cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.caller", keys, vals, 1); - ASSERT_STR_EQ(r.qualified_name, "proj.handler.Process"); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.exact.skip reason=changed_batch_too_large") != NULL); + ASSERT(strstr(logs, "pass=incr_semantic_edges") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_CONTAINMENT); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "changed_batch_too_large"); + cbm_pipeline_free(p); - cbm_registry_free(reg); + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(pipeline_test_derived_status_is(s, project, + CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_STATUS_STALE)); + cbm_store_close(s); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_fuzzy_import_penalty) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function"); +TEST(incremental_fast_mixed_unowned_edge_frontier_falls_back_to_full_rebuild) { + enum { PIPELINE_CONFIGURED_AFFECTED_CAP = CBM_SZ_8 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* Has imports but billing not imported → confidence halved */ - const char *keys[] = {"other"}; - const char *vals[] = {"proj.other"}; - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "unknown.Handler", "proj.caller", keys, vals, 1); - ASSERT_TRUE(fr.ok); - /* 0.40 * 0.5 = 0.20 */ - ASSERT(fr.result.confidence > 0.15 && fr.result.confidence <= 0.25); + ASSERT_EQ(write_incremental_frontier_fixture(CBM_ALLOC_ONE), 0); - cbm_registry_free(reg); - PASS(); -} + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + char cap_value[CBM_SZ_32]; + int n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_CONFIGURED_AFFECTED_CAP); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), 0); -TEST(registry_fuzzy_no_import_map_passthrough) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function"); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* NULL import map → no penalty, full fuzzy confidence */ - cbm_fuzzy_result_t fr = - cbm_registry_fuzzy_resolve(reg, "unknown.Handler", "proj.caller", NULL, NULL, 0); - ASSERT_TRUE(fr.ok); - ASSERT(fr.result.confidence > 0.35 && fr.result.confidence <= 0.45); + ASSERT_EQ(pipeline_store_insert_file_owned_unowned_source_edge(g_incr_dbpath, project, + "leaf.go", "Leaf", "CALLS"), + CBM_STORE_OK); + ASSERT_EQ(write_incremental_leaf_file_with_extra(CBM_SZ_2), 0); - cbm_registry_free(reg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + char exact_fallback_log[CBM_SZ_128]; + n = snprintf(exact_fallback_log, sizeof(exact_fallback_log), + "msg=incremental.exact.fallback reason=%s", + CBM_PIPELINE_DELTA_REASON_INBOUND_EDGES_REQUIRE_FULL); + ASSERT(n >= 0 && (size_t)n < sizeof(exact_fallback_log)); + ASSERT(strstr(logs, exact_fallback_log) != NULL); + char full_fallback_log[CBM_SZ_128]; + n = snprintf(full_fallback_log, sizeof(full_fallback_log), + "msg=incremental.fallback reason=%s", + CBM_PIPELINE_DELTA_REASON_INBOUND_EDGES_REQUIRE_FULL); + ASSERT(n >= 0 && (size_t)n < sizeof(full_fallback_log)); + ASSERT(strstr(logs, full_fallback_log) != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.frontier") == NULL); + ASSERT(strstr(logs, "msg=incremental.exact.done") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), + CBM_PIPELINE_DELTA_REASON_INBOUND_EDGES_REQUIRE_FULL); + cbm_pipeline_free(p); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "LeafExtra")); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "inbound full fallback differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(registry_find_by_name) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); - cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); - cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); - cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method"); +TEST(incremental_fast_expands_small_inbound_frontier_and_matches_full) { + enum { + PIPELINE_EXPECTED_EXACT_FILES = 3, + }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* FindByName returns all entries for "Foo" */ - const char **foos = NULL; - int foos_count = 0; - cbm_registry_find_by_name(reg, "Foo", &foos, &foos_count); - ASSERT_EQ(foos_count, 2); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n" + "\treturn 1\n" + "}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Helper() int {\n" + "\treturn Leaf()\n" + "}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func main() {\n" + "\tHelper()\n" + "}\n"), + 0); - /* FindByName for unique "Bar" */ - const char **bars = NULL; - int bars_count = 0; - cbm_registry_find_by_name(reg, "Bar", &bars, &bars_count); - ASSERT_EQ(bars_count, 1); - ASSERT_STR_EQ(bars[0], "proj.pkg.Bar"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* FindByName for "transform" */ - const char **transforms = NULL; - int trans_count = 0; - cbm_registry_find_by_name(reg, "transform", &transforms, &trans_count); - ASSERT_EQ(trans_count, 1); - ASSERT_STR_EQ(transforms[0], "proj.utils.DataProcessor.transform"); + n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n" + "\tfor i := 0; i < 10; i++ {\n" + "\t}\n" + "\treturn 2\n" + "}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.classify changed=1") != NULL); + char frontier_log[CBM_SZ_128]; + n = snprintf(frontier_log, sizeof(frontier_log), + "msg=incremental.exact.frontier changed=1 expanded=%d", + PIPELINE_EXPECTED_EXACT_FILES); + ASSERT(n >= 0 && (size_t)n < sizeof(frontier_log)); + ASSERT(strstr(logs, frontier_log) != NULL); + char done_log[CBM_SZ_128]; + n = snprintf(done_log, sizeof(done_log), "msg=incremental.exact.done files=%d", + PIPELINE_EXPECTED_EXACT_FILES); + ASSERT(n >= 0 && (size_t)n < sizeof(done_log)); + ASSERT(strstr(logs, done_log) != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); - /* label_of */ - ASSERT_STR_EQ(cbm_registry_label_of(reg, "proj.utils.DataProcessor.transform"), "Method"); - ASSERT_STR_EQ(cbm_registry_label_of(reg, "proj.pkg.Foo"), "Function"); + int64_t leaf_generation = 0; + int64_t helper_generation = 0; + int64_t main_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "leaf.go", + &leaf_generation), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &helper_generation), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "main.go", + &main_generation), + CBM_STORE_OK); + ASSERT_GT(leaf_generation, CBM_PIPELINE_COMPAT_GENERATION); + ASSERT_EQ(leaf_generation, helper_generation); + ASSERT_EQ(helper_generation, main_generation); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "small inbound exact frontier differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); - /* Total size */ - ASSERT_EQ(cbm_registry_size(reg), 4); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} - /* Resolve same-module */ - cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0); - ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo"); +TEST(incremental_fast_three_file_batch_falls_back_to_full_rebuild_parity) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* Resolve via import map */ - const char *keys[] = {"other"}; - const char *vals[] = {"proj.other"}; - r = cbm_registry_resolve(reg, "other.Foo", "proj.pkg", keys, vals, 1); - ASSERT_STR_EQ(r.qualified_name, "proj.other.Foo"); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 1\n}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* Resolve unique name */ - r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0); - ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar"); + n = snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func main() {\n\tHelper()\n\tNewHelper()\n\tLeaf()\n}\n\n" + "func NewMain() int {\n\treturn 11\n}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Helper() string {\n\treturn \"updated\"\n}\n\n" + "func NewHelper() int {\n\treturn 13\n}\n"), + 0); + n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func NewLeaf() int {\n\treturn 17\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_CONTAINMENT); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "changed_batch_too_large"); + cbm_pipeline_free(p); - cbm_registry_free(reg); - PASS(); -} + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewMain")); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewHelper")); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewLeaf")); + int64_t main_generation = 0; + int64_t helper_generation = 0; + int64_t leaf_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "main.go", + &main_generation), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &helper_generation), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "leaf.go", + &leaf_generation), + CBM_STORE_OK); + ASSERT_EQ(main_generation, CBM_PIPELINE_COMPAT_GENERATION); + ASSERT_EQ(main_generation, helper_generation); + ASSERT_EQ(main_generation, leaf_generation); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "three-file fallback differed from fresh FAST rebuild"); + } + ASSERT_EQ(diff_rc, 0); -TEST(registry_confidence_band) { - ASSERT_STR_EQ(cbm_confidence_band(0.95), "high"); - ASSERT_STR_EQ(cbm_confidence_band(0.70), "high"); - ASSERT_STR_EQ(cbm_confidence_band(0.55), "medium"); - ASSERT_STR_EQ(cbm_confidence_band(0.45), "medium"); - ASSERT_STR_EQ(cbm_confidence_band(0.40), "speculative"); - ASSERT_STR_EQ(cbm_confidence_band(0.25), "speculative"); - ASSERT_STR_EQ(cbm_confidence_band(0.20), ""); - ASSERT_STR_EQ(cbm_confidence_band(0.0), ""); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(infra_qn_helper) { - /* Port of TestInfraQN */ +TEST(incremental_fast_single_delete_exact_matches_full_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* Regular infra file → __infra__ suffix */ - char *qn = cbm_infra_qn("myproject", "docker-images/service/Dockerfile", "dockerfile", NULL); - ASSERT_NOT_NULL(qn); - ASSERT(strstr(qn, ".__infra__") != NULL); - free(qn); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 1\n}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Leaf")); - /* Compose service → ::service_name suffix */ - qn = cbm_infra_qn("myproject", "docker-compose.yml", "compose-service", "web"); - ASSERT_NOT_NULL(qn); - ASSERT(strstr(qn, "::web") != NULL); - free(qn); + ASSERT_EQ(cbm_unlink(path), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "Leaf")); + int64_t leaf_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "leaf.go", + &leaf_generation), + CBM_STORE_NOT_FOUND); + ASSERT_GT(pipeline_store_completed_generation_count(g_incr_dbpath, project), 0); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "single-delete exact differed from fresh FAST rebuild"); + } + ASSERT_EQ(diff_rc, 0); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── Infrascan integration tests ────────────────────────────────── */ +TEST(incremental_fast_delete_falls_back_to_full_rebuild_parity) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -TEST(infra_pipeline_integration) { - /* Port of TestPassInfraFilesIntegration (Dockerfile + .env parts). - * Tests parse functions on source text (pipeline infrascan pass not - * wired yet — compose YAML also blocked on YAML parser). */ + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); - /* Parse Dockerfile */ - cbm_dockerfile_result_t dr; - ASSERT_EQ(cbm_parse_dockerfile_source("FROM alpine:3.19\nEXPOSE 8080\n", &dr), 0); - ASSERT_STR_EQ(dr.base_image, "alpine:3.19"); - ASSERT_GTE(dr.port_count, 1); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(cbm_unlink(path), 0); - /* Parse .env */ - cbm_dotenv_result_t er; - ASSERT_EQ(cbm_parse_dotenv_source("APP_PORT=8080\nDEBUG=true\n", &er), 0); - ASSERT_GTE(er.env_count, 1); - /* APP_PORT should be present */ - bool found_port = false; - for (int i = 0; i < er.env_count; i++) { - if (strcmp(er.env_vars[i].key, "APP_PORT") == 0 && - strcmp(er.env_vars[i].value, "8080") == 0) - found_port = true; + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); + int64_t main_generation = 0; + int64_t helper_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "main.go", + &main_generation), + CBM_STORE_OK); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &helper_generation), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(main_generation, CBM_PIPELINE_COMPAT_GENERATION); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "delete fallback differed from fresh FAST rebuild"); } - ASSERT_TRUE(found_port); + ASSERT_EQ(diff_rc, 0); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(infra_pipeline_idempotent) { - /* Port of TestPassInfraFilesIdempotent: - * Parsing same source twice should produce identical results. */ - const char *src = "FROM alpine:3.19\nEXPOSE 8080\nENV PORT=8080\n"; - cbm_dockerfile_result_t r1, r2; - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r1), 0); - ASSERT_EQ(cbm_parse_dockerfile_source(src, &r2), 0); +TEST(incremental_fast_rename_like_batch_falls_back_to_full_rebuild_parity) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - ASSERT_STR_EQ(r1.base_image, r2.base_image); - ASSERT_EQ(r1.port_count, r2.port_count); - ASSERT_EQ(r1.env_count, r2.env_count); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(cbm_unlink(path), 0); + n = snprintf(path, sizeof(path), "%s/helper2.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func RenamedHelper() string {\n\treturn \"renamed\"\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); + + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "RenamedHelper")); + int64_t helper_generation = 0; + int64_t helper2_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper.go", + &helper_generation), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "helper2.go", + &helper2_generation), + CBM_STORE_OK); + ASSERT_EQ(helper2_generation, CBM_PIPELINE_COMPAT_GENERATION); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "rename-like fallback differed from fresh FAST rebuild"); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── K8s / Kustomize extraction tests ──────────────────────────── */ +TEST(incremental_fast_new_folder_exact_delta_parity) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -TEST(k8s_extract_kustomize) { - const char *src = "apiVersion: kustomize.config.k8s.io/v1beta1\n" - "kind: Kustomization\n" - "resources:\n" - " - deployment.yaml\n" - " - service.yaml\n"; - CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_KUSTOMIZE, "myproj", - "base/kustomization.yaml", 0, NULL, NULL); - ASSERT(r != NULL); - ASSERT_GTE(r->imports.count, 2); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - bool found_deploy = false, found_svc = false; - for (int i = 0; i < r->imports.count; i++) { - if (r->imports.items[i].module_path && - strcmp(r->imports.items[i].module_path, "deployment.yaml") == 0) - found_deploy = true; - if (r->imports.items[i].module_path && - strcmp(r->imports.items[i].module_path, "service.yaml") == 0) - found_svc = true; + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/pkg", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_TRUE(cbm_mkdir_p(path, 0755)); + n = snprintf(path, sizeof(path), "%s/pkg/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package pkg\n\n" + "func FolderLeaf() int {\n\treturn 23\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.overlay.done") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); + + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "FolderLeaf")); + int64_t generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "pkg/leaf.go", + &generation), + CBM_STORE_OK); + ASSERT_GT(generation, CBM_PIPELINE_COMPAT_GENERATION); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "new-folder exact delta differed from fresh FAST rebuild"); } - ASSERT_TRUE(found_deploy); - ASSERT_TRUE(found_svc); + ASSERT_EQ(diff_rc, 0); - cbm_free_result(r); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(k8s_extract_manifest) { - const char *src = "apiVersion: apps/v1\n" - "kind: Deployment\n" - "metadata:\n" - " name: my-app\n" - " namespace: production\n"; - CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, "myproj", - "k8s/deployment.yaml", 0, NULL, NULL); - ASSERT(r != NULL); - ASSERT_GTE(r->defs.count, 1); +TEST(incremental_fast_route_decorator_change_matches_fresh_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/routes.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "from fastapi import FastAPI\n\n" + "app = FastAPI()\n\n" + "@app.get('/api/orders')\n" + "def orders():\n" + " return {'ok': True}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_route_name(g_incr_dbpath, project, "/api/orders")); + + ASSERT_EQ(th_write_file(path, + "from fastapi import FastAPI\n\n" + "app = FastAPI()\n\n" + "@app.get('/api/items')\n" + "def orders():\n" + " return {'ok': True}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); - bool found_resource = false; - for (int d = 0; d < r->defs.count; d++) { - if (r->defs.items[d].label && strcmp(r->defs.items[d].label, "Resource") == 0 && - r->defs.items[d].name && strstr(r->defs.items[d].name, "Deployment") != NULL) - found_resource = true; + ASSERT(!pipeline_store_has_route_name(g_incr_dbpath, project, "/api/orders")); + ASSERT(pipeline_store_has_route_name(g_incr_dbpath, project, "/api/items")); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "route decorator incremental differed from fresh FAST rebuild"); } - ASSERT_TRUE(found_resource); + ASSERT_EQ(diff_rc, 0); - cbm_free_result(r); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(k8s_extract_manifest_no_name) { - const char *src = "apiVersion: apps/v1\nkind: Deployment\n"; - CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, "myproj", - "k8s/deploy.yaml", 0, NULL, NULL); - ASSERT(r != NULL); - /* No crash — defs count may be 0 because metadata.name is absent */ - ASSERT(!r->has_error); - cbm_free_result(r); - PASS(); -} +TEST(incremental_fast_arg_url_route_change_matches_parallel_full_rebuild) { + enum { ARG_URL_FILLER_FILES = 52, ARG_URL_WORKERS = 4 }; + pipeline_env_snapshot_t workers_env = pipeline_env_save("CBM_WORKERS"); + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -TEST(k8s_extract_manifest_multidoc) { - /* Two-document YAML separated by "---". - * extract_k8s_manifest contains a "break" after the first successful push, - * so it processes only the first document that has both kind and - * metadata.name. This test pins that behaviour: the first document's - * resource must be present and no crash must occur. - * - * Note: with some tree-sitter YAML grammar versions the root stream may - * expose both documents as siblings; the break still fires after the first - * successful def push, so defs.count must be exactly 1. */ - const char *src = "apiVersion: apps/v1\n" - "kind: Deployment\n" - "metadata:\n" - " name: my-app\n" - "---\n" - "apiVersion: v1\n" - "kind: Service\n" - "metadata:\n" - " name: my-svc\n"; - CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), CBM_LANG_K8S, "myproj", - "k8s/multi.yaml", 0, NULL, NULL); - ASSERT(r != NULL); - ASSERT(!r->has_error); - /* First document's resource must be present */ - int found = 0; - for (int i = 0; i < r->defs.count; i++) { - if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Resource") == 0 && - r->defs.items[i].name && strcmp(r->defs.items[i].name, "Deployment/my-app") == 0) { - found = 1; - } + char worker_buf[CBM_SZ_32]; + int n = snprintf(worker_buf, sizeof(worker_buf), "%d", ARG_URL_WORKERS); + ASSERT(n > 0 && (size_t)n < sizeof(worker_buf)); + ASSERT_EQ(cbm_setenv("CBM_WORKERS", worker_buf, 1), 0); + + for (int i = 0; i < ARG_URL_FILLER_FILES; i++) { + char path[CBM_PATH_MAX]; + char body[CBM_SZ_256]; + n = snprintf(path, sizeof(path), "%s/filler_%02d.c", g_incr_tmpdir, i); + ASSERT(n > 0 && (size_t)n < sizeof(path)); + n = snprintf(body, sizeof(body), "int filler_%02d(void) { return %d; }\n", i, i); + ASSERT(n > 0 && (size_t)n < sizeof(body)); + ASSERT_EQ(th_write_file(path, body), 0); } - ASSERT(found); - /* At least one def, no more than one (only first document processed) */ - ASSERT(r->defs.count >= 1); - cbm_free_result(r); - PASS(); -} + ASSERT_EQ(write_incremental_arg_url_route_file("/api/index", 1), 0); -/* ── Envscan tests (port of envscan_test.go) ───────────────────── */ + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_route_name(g_incr_dbpath, project, "/api/index")); -/* Helper: write a file inside a temp dir */ -static void write_temp_file(const char *dir, const char *name, const char *content) { - char path[512]; - /* Create subdirectories if needed */ - snprintf(path, sizeof(path), "%s/%s", dir, name); - char *slash = strrchr(path, '/'); - if (slash) { - char parent[512]; - size_t plen = slash - path; - memcpy(parent, path, plen); - parent[plen] = '\0'; - /* mkdir -p (simple version, one level) */ - cbm_mkdir(parent); - } - FILE *f = fopen(path, "w"); - if (f) { - fputs(content, f); - fclose(f); + ASSERT_EQ(write_incremental_arg_url_route_file("/api/index-status", 2), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_publish_kind_t kind = cbm_pipeline_publish_kind(p); + ASSERT(kind == CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT || + kind == CBM_PIPELINE_PUBLISH_INCREMENTAL_CONTAINMENT); + cbm_pipeline_free(p); + + ASSERT(!pipeline_store_has_route_name(g_incr_dbpath, project, "/api/index")); + ASSERT(pipeline_store_has_route_name(g_incr_dbpath, project, "/api/index-status")); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "arg-url route incremental differed from fresh FAST rebuild"); } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + pipeline_env_restore(&workers_env); + PASS(); } -/* Helper: find binding by key in results */ -static const cbm_env_binding_t *find_binding_by_key(const cbm_env_binding_t *bindings, int count, - const char *key) { - for (int i = 0; i < count; i++) { - if (strcmp(bindings[i].key, key) == 0) - return &bindings[i]; +TEST(incremental_fast_exact_scratch_multifile_usage_edges_match_fresh) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - return NULL; + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + char main_path[CBM_PATH_MAX]; + char helper_path[CBM_PATH_MAX]; + int n = snprintf(main_path, sizeof(main_path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(main_path)); + n = snprintf(helper_path, sizeof(helper_path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(helper_path)); + ASSERT_EQ(th_write_file(main_path, + "package main\n\n" + "func main() {\n\tHelper()\n\tNewHelper()\n}\n\n" + "func NewMain() int {\n\treturn 11\n}\n"), + 0); + ASSERT_EQ(th_write_file(helper_path, + "package main\n\n" + "func Helper() string {\n\treturn \"updated\"\n}\n\n" + "func NewHelper() int {\n\treturn 13\n}\n"), + 0); + + cbm_file_info_t changed[] = { + {.path = main_path, .rel_path = "main.go", .language = CBM_LANG_GO}, + {.path = helper_path, .rel_path = "helper.go", .language = CBM_LANG_GO}, + }; + cbm_store_t *store = cbm_store_open_path_query(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t deltas[CBM_SZ_2] = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files( + store, g_incr_tmpdir, project, changed, + (int)(sizeof(changed) / sizeof(changed[0])), &scratch, deltas), + CBM_STORE_OK); + + char *helper_file_qn = cbm_pipeline_fqn_compute(project, "helper.go", "__file__"); + static const char *function_labels[] = {"Function"}; + const cbm_gbuf_node_t *main_fn = cbm_gbuf_resolve_by_name_in_file( + scratch, "main", "main.go", function_labels, + (int)(sizeof(function_labels) / sizeof(function_labels[0]))); + ASSERT_NOT_NULL(helper_file_qn); + ASSERT_NOT_NULL(main_fn); + ASSERT_NOT_NULL(main_fn->qualified_name); + ASSERT_EQ(pipeline_gbuf_count_usage_edge(scratch, helper_file_qn, main_fn->qualified_name, + "main"), + 1); + ASSERT_EQ(pipeline_file_delta_count_usage_edge(&deltas[1], helper_file_qn, + main_fn->qualified_name, "main"), + 1); + + free(helper_file_qn); + cbm_pipeline_file_delta_free(&deltas[0]); + cbm_pipeline_file_delta_free(&deltas[1]); + cbm_gbuf_free(scratch); + cbm_store_close(store); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); } -/* Helper: find binding by value in results */ -static int has_binding_value(const cbm_env_binding_t *bindings, int count, const char *value) { - for (int i = 0; i < count; i++) { - if (strcmp(bindings[i].value, value) == 0) - return 1; +TEST(incremental_fast_exact_batch_publish_matches_fresh_rebuild_for_two_file_go) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - return 0; -} -TEST(envscan_dockerfile_env_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_dock_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char pass_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(p, pass_fingerprint, + sizeof(pass_fingerprint)), + CBM_STORE_OK); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - write_temp_file(tmpdir, "Dockerfile", - "FROM python:3.9-slim\n" - "ENV ORDER_URL=https://api.example.com/api/orders\n" - "ENV DB_HOST=localhost\n" - "ARG WEBHOOK_URL=https://hooks.example.com/webhook\n"); + char main_path[CBM_PATH_MAX]; + char helper_path[CBM_PATH_MAX]; + int n = snprintf(main_path, sizeof(main_path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(main_path)); + n = snprintf(helper_path, sizeof(helper_path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(helper_path)); + ASSERT_EQ(th_write_file(main_path, + "package main\n\n" + "func main() {\n\tHelper()\n\tNewHelper()\n}\n\n" + "func NewMain() int {\n\treturn 11\n}\n"), + 0); + ASSERT_EQ(th_write_file(helper_path, + "package main\n\n" + "func Helper() string {\n\treturn \"updated\"\n}\n\n" + "func NewHelper() int {\n\treturn 13\n}\n"), + 0); + + cbm_file_info_t changed[] = { + {.path = main_path, .rel_path = "main.go", .language = CBM_LANG_GO}, + {.path = helper_path, .rel_path = "helper.go", .language = CBM_LANG_GO}, + }; + const int changed_count = (int)(sizeof(changed) / sizeof(changed[0])); + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t deltas[CBM_SZ_2] = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files(store, g_incr_tmpdir, project, + changed, changed_count, &scratch, + deltas), + CBM_STORE_OK); + for (int i = 0; i < changed_count; i++) { + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata_with_fingerprint( + &deltas[i], &changed[i], pass_fingerprint), + CBM_STORE_OK); + } - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + const cbm_pipeline_file_delta_t *delta_ptrs[CBM_SZ_2] = {&deltas[0], &deltas[1]}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_plan_file_delta_batch(store, delta_ptrs, changed_count, + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS, + &plan), + CBM_STORE_OK); + if (plan.route != CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE) { + FAIL(plan.reason ? plan.reason : "exact batch plan rejected candidate"); + } + cbm_pipeline_file_delta_plan_free(&plan); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "ORDER_URL")); - ASSERT_STR_EQ(find_binding_by_key(bindings, count, "ORDER_URL")->value, - "https://api.example.com/api/orders"); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "WEBHOOK_URL")); - ASSERT_STR_EQ(find_binding_by_key(bindings, count, "WEBHOOK_URL")->value, - "https://hooks.example.com/webhook"); - /* DB_HOST=localhost is NOT a URL → should be absent */ - ASSERT_TRUE(find_binding_by_key(bindings, count, "DB_HOST") == NULL); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(store, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, CBM_PIPELINE_COMPAT_GENERATION); + for (int i = 0; i < changed_count; i++) { + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&deltas[i], generation), + CBM_STORE_OK); + } + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(store, delta_ptrs, changed_count, + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS, + &plan), + CBM_STORE_OK); + if (plan.route != CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE) { + const char *store_err = cbm_store_error(store); + if (store_err && store_err[0]) { + FAIL(store_err); + } + FAIL(plan.reason ? plan.reason : "exact batch apply rejected candidate"); + } + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(store); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "exact batch publish differed from fresh FAST rebuild"); + } - th_rmtree(tmpdir); + cbm_pipeline_file_delta_free(&deltas[0]); + cbm_pipeline_file_delta_free(&deltas[1]); + cbm_gbuf_free(scratch); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_shell_env_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sh_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); - - write_temp_file(tmpdir, "setup.sh", - "#!/bin/bash\n" - "export DB_URL=\"https://db.example.com/api/sync\"\n" - "APP_NAME=\"my-service\"\n" - "CALLBACK_URL=https://hooks.example.com/notify\n"); +TEST(incremental_overlay_producer_marks_dirty_ready_without_canonical_mutation) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char pass_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(p, pass_fingerprint, + sizeof(pass_fingerprint)), + CBM_STORE_OK); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayOnly")); + + char helper_path[CBM_PATH_MAX]; + int n = snprintf(helper_path, sizeof(helper_path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(helper_path)); + ASSERT_EQ(th_write_file(helper_path, + "package main\n\n" + "func Helper() string {\n\treturn \"overlay\"\n}\n\n" + "func OverlayOnly() int {\n\treturn 21\n}\n"), + 0); + + cbm_file_info_t changed = { + .path = helper_path, + .rel_path = "helper.go", + .language = CBM_LANG_GO, + }; + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files(store, g_incr_tmpdir, project, + &changed, 1, &scratch, &delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata_with_fingerprint(&delta, &changed, + pass_fingerprint), + CBM_STORE_OK); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "DB_URL")); - ASSERT_STR_EQ(find_binding_by_key(bindings, count, "DB_URL")->value, - "https://db.example.com/api/sync"); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CALLBACK_URL")); - /* APP_NAME is NOT a URL → absent */ - ASSERT_TRUE(find_binding_by_key(bindings, count, "APP_NAME") == NULL); + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_pipeline_publish_overlay_file_delta_batch( + store, deltas, 1, CBM_PIPELINE_COMPAT_GENERATION, + CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, &overlay_generation), + CBM_STORE_OK); + ASSERT_GT(overlay_generation, 0); + cbm_store_close(store); + + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayOnly")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "helper.go", + "OverlayOnly")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); - th_rmtree(tmpdir); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_env_file_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_env_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); - - write_temp_file(tmpdir, ".env", - "\nAPI_URL=https://api.example.com/v1\n" - "DEBUG=true\n" - "SERVICE_URL=https://service.example.com/api\n"); +TEST(incremental_overlay_publish_small_deltas_keeps_canonical_base_visible) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayRunOnly")); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func OverlayRunOnly() int {\n\treturn 77\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=1") != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + ASSERT(!cbm_pipeline_graph_changed(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, 1); + ASSERT_EQ(stats.published_paths, 1); + cbm_pipeline_free(p); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "API_URL")); - ASSERT_STR_EQ(find_binding_by_key(bindings, count, "API_URL")->value, - "https://api.example.com/v1"); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "SERVICE_URL")); - /* DEBUG=true is NOT a URL */ - ASSERT_TRUE(find_binding_by_key(bindings, count, "DEBUG") == NULL); + ASSERT_EQ(pipeline_store_generation_status_count(g_incr_dbpath, project, + CBM_STORE_INDEX_STATUS_RESERVED), + 0); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayRunOnly")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "leaf.go", + "OverlayRunOnly")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); - th_rmtree(tmpdir); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_toml_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_toml_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(incremental_exact_python_scoped_lsp_gap_matches_full_rebuild) { + enum { PIPELINE_EXACT_AFFECTED_PATHS = 2 }; + enum { PIPELINE_EXACT_PUBLISHED_PATHS = 1 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - write_temp_file(tmpdir, "config.toml", - "[service]\n" - "base_url = \"https://api.example.com\"\n" - "name = \"my-service\"\n" - "callback_url = \"https://hooks.example.com/notify\"\n"); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/app.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "def helper():\n" + " return 1\n"), + 0); + char consumer_path[CBM_PATH_MAX]; + n = snprintf(consumer_path, sizeof(consumer_path), "%s/consumer.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(consumer_path)); + ASSERT_EQ(th_write_file(consumer_path, + "from app import helper\n\n" + "def use_helper():\n" + " return helper()\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + char cap_value[CBM_SZ_32]; + n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_EXACT_AFFECTED_PATHS); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_CHANGED_PATHS, cap_value), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + ASSERT_EQ(th_write_file(path, + "def helper():\n" + " return 2\n\n" + "def py_overlay_gap_marker():\n" + " return helper()\n"), + 0); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "base_url")); - ASSERT_STR_EQ(find_binding_by_key(bindings, count, "base_url")->value, - "https://api.example.com"); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "callback_url")); - /* name="my-service" is NOT a URL */ - ASSERT_TRUE(find_binding_by_key(bindings, count, "name") == NULL); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.exact.skip reason=scoped_lsp_gap") == NULL); + ASSERT(strstr(logs, "msg=incremental.fallback reason=scoped_lsp_gap") == NULL); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, PIPELINE_EXACT_AFFECTED_PATHS); + ASSERT_EQ(stats.published_paths, PIPELINE_EXACT_PUBLISHED_PATHS); + cbm_pipeline_free(p); - th_rmtree(tmpdir); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err + : "Python scoped-LSP exact reindex differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_yaml_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_yaml_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(incremental_javascript_scoped_lsp_gap_reports_full_rebuild_not_cap_overflow) { + enum { PIPELINE_EXACT_AFFECTED_CAP = 64 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - write_temp_file(tmpdir, "config.yaml", - "service:\n" - " service_url: \"https://api.internal.com/api/process\"\n" - " timeout: 30\n" - " callback_url: \"https://hooks.internal.com/callback\"\n"); + char leaf_path[CBM_PATH_MAX]; + int n = snprintf(leaf_path, sizeof(leaf_path), "%s/leaf.js", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(leaf_path)); + ASSERT_EQ(th_write_file(leaf_path, "export function leaf() { return 1; }\n"), 0); + char consumer_path[CBM_PATH_MAX]; + n = snprintf(consumer_path, sizeof(consumer_path), "%s/consumer.js", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(consumer_path)); + ASSERT_EQ(th_write_file(consumer_path, + "import { leaf } from './leaf.js';\n" + "export function consume() { return leaf(); }\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + char cap_value[CBM_SZ_32]; + n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_EXACT_AFFECTED_CAP); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + ASSERT_EQ(th_write_file(leaf_path, + "export function leaf() { return 2; }\n" + "export function leafExtra() { return leaf() + 1; }\n"), + 0); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.exact.skip reason=scoped_lsp_gap") != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "scoped_lsp_gap"); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, 1); + ASSERT_EQ(stats.affected_paths_limit, PIPELINE_EXACT_AFFECTED_CAP); + ASSERT_FALSE(stats.affected_paths_truncated); + cbm_pipeline_free(p); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "service_url")); - ASSERT_STR_EQ(find_binding_by_key(bindings, count, "service_url")->value, - "https://api.internal.com/api/process"); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "callback_url")); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err + : "JavaScript scoped-LSP fallback differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); - th_rmtree(tmpdir); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_terraform_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_tf_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +typedef struct { + const char *name; + const char *filename; + const char *initial_source; + const char *updated_source; + cbm_pipeline_publish_kind_t expected_publish_kind; +} incremental_language_oracle_case_t; + +static int run_incremental_language_oracle_case(const incremental_language_oracle_case_t *tc, + char *err, size_t err_sz) { + char root[CBM_PATH_MAX]; + const char *cache = cbm_resolve_cache_dir(); + int n = snprintf(root, sizeof(root), "%s/cbm-incr-language-%s-XXXXXX", cache, tc->name); + if (n < 0 || (size_t)n >= sizeof(root) || !cbm_mkdtemp(root)) { + snprintf(err, err_sz, "%s: fixture directory creation failed", tc->name); + return CBM_STORE_ERR; + } - write_temp_file(tmpdir, "variables.tf", - "variable \"webhook_url\" {\n" - " description = \"Webhook endpoint\"\n" - " default = \"https://api.example.com/webhook\"\n" - "}\n\n" - "variable \"region\" {\n" - " default = \"us-east-1\"\n" - "}\n"); + int rc = CBM_STORE_ERR; + char source_path[CBM_PATH_MAX]; + char db_path[CBM_PATH_MAX]; + char *project = NULL; + cbm_config_t *cfg = NULL; + cbm_pipeline_t *pipeline = NULL; + n = snprintf(source_path, sizeof(source_path), "%s/%s", root, tc->filename); + if (n < 0 || (size_t)n >= sizeof(source_path) || + th_write_file(source_path, tc->initial_source) != 0) { + snprintf(err, err_sz, "%s: initial source write failed", tc->name); + goto cleanup; + } + n = snprintf(db_path, sizeof(db_path), "%s/graph.db", root); + if (n < 0 || (size_t)n >= sizeof(db_path)) { + snprintf(err, err_sz, "%s: database path overflow", tc->name); + goto cleanup; + } - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + cfg = incremental_test_config(root); + if (!cfg || cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH) != 0) { + snprintf(err, err_sz, "%s: incremental config setup failed", tc->name); + goto cleanup; + } + pipeline = cbm_pipeline_new(root, db_path, CBM_MODE_FAST); + if (!pipeline) { + snprintf(err, err_sz, "%s: initial pipeline allocation failed", tc->name); + goto cleanup; + } + cbm_pipeline_apply_config(pipeline, cfg); + if (cbm_pipeline_run(pipeline) != 0) { + snprintf(err, err_sz, "%s: initial full publication failed", tc->name); + goto cleanup; + } + project = cbm_strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + pipeline = NULL; + if (!project) { + snprintf(err, err_sz, "%s: project name allocation failed", tc->name); + goto cleanup; + } - ASSERT_GTE(count, 1); - ASSERT_TRUE(has_binding_value(bindings, count, "https://api.example.com/webhook")); + if (th_write_file(source_path, tc->updated_source) != 0) { + snprintf(err, err_sz, "%s: updated source write failed", tc->name); + goto cleanup; + } + pipeline = cbm_pipeline_new(root, db_path, CBM_MODE_FAST); + if (!pipeline) { + snprintf(err, err_sz, "%s: incremental pipeline allocation failed", tc->name); + goto cleanup; + } + cbm_pipeline_apply_config(pipeline, cfg); + if (cbm_pipeline_run(pipeline) != 0) { + snprintf(err, err_sz, "%s: incremental publication failed", tc->name); + goto cleanup; + } + cbm_pipeline_publish_kind_t actual_kind = cbm_pipeline_publish_kind(pipeline); + const char *actual_reason = cbm_pipeline_publish_reason(pipeline); + if (actual_kind != tc->expected_publish_kind) { + snprintf(err, err_sz, "%s: publish kind=%d reason=%s, expected=%d", tc->name, actual_kind, + actual_reason ? actual_reason : "", tc->expected_publish_kind); + goto cleanup; + } + if (actual_kind == CBM_PIPELINE_PUBLISH_FULL && + (!actual_reason || strcmp(actual_reason, "scoped_lsp_gap") != 0)) { + snprintf(err, err_sz, "%s: full fallback reason=%s, expected=scoped_lsp_gap", tc->name, + actual_reason ? actual_reason : ""); + goto cleanup; + } + cbm_pipeline_free(pipeline); + pipeline = NULL; - th_rmtree(tmpdir); + rc = + pipeline_compare_current_db_to_fresh_fast_rebuild(root, db_path, project, cfg, err, err_sz); + if (rc != 0 && (!err || err[0] == '\0')) { + snprintf(err, err_sz, "%s: incremental graph differed from fresh rebuild", tc->name); + } + +cleanup: + cbm_pipeline_free(pipeline); + free(project); + cbm_config_close(cfg); + const char *artifact_dir = getenv("CBM_TEST_ARTIFACT_DIR"); + if (rc != 0 && artifact_dir && artifact_dir[0] != '\0') { + printf(" [incremental-language-artifact] %s\n", root); + } else { + th_rmtree(root); + } + return rc; +} + +TEST(incremental_cross_lsp_language_matrix_matches_fresh_rebuild) { + static const incremental_language_oracle_case_t cases[] = { + {"go", "sample.go", "package sample\n\nfunc Value() int { return 1 }\n", + "package sample\n\nfunc Value() int { return 2 }\nfunc Added() int { return Value() }\n", + CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT}, + {"c", "sample.c", "int matrix_value(void) { return 1; }\n", + "int matrix_value(void) { return 2; }\nint matrix_added(void) { return matrix_value(); " + "}\n", + CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT}, + {"cpp", "sample.cpp", "int matrix_value() { return 1; }\n", + "int matrix_value() { return 2; }\nint matrix_added() { return matrix_value(); }\n", + CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT}, + {"cuda", "sample.cu", "__device__ int matrix_value() { return 1; }\n", + "__device__ int matrix_value() { return 2; }\n" + "__device__ int matrix_added() { return matrix_value(); }\n", + CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT}, + {"python", "sample.py", "def matrix_value():\n return 1\n", + "def matrix_value():\n return 2\n\ndef matrix_added():\n return matrix_value()\n", + CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT}, + {"javascript", "sample.js", "export function matrixValue() { return 1; }\n", + "export function matrixValue() { return 2; }\n" + "export function matrixAdded() { return matrixValue(); }\n", + CBM_PIPELINE_PUBLISH_FULL}, + {"typescript", "sample.ts", "export function matrixValue(): number { return 1; }\n", + "export function matrixValue(): number { return 2; }\n" + "export function matrixAdded(): number { return matrixValue(); }\n", + CBM_PIPELINE_PUBLISH_FULL}, + {"tsx", "sample.tsx", "export function MatrixValue(): number { return 1; }\n", + "export function MatrixValue(): number { return 2; }\n" + "export function MatrixAdded(): number { return MatrixValue(); }\n", + CBM_PIPELINE_PUBLISH_FULL}, + {"php", "sample.php", " i32 { 1 }\n", + "pub fn matrix_value() -> i32 { 2 }\n" + "pub fn matrix_added() -> i32 { matrix_value() }\n", + CBM_PIPELINE_PUBLISH_FULL}, + }; + char err[CBM_SZ_8K]; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + err[0] = '\0'; + int rc = run_incremental_language_oracle_case(&cases[i], err, sizeof(err)); + if (rc != 0) { + FAIL(err[0] ? err : "incremental language oracle failed"); + } + } PASS(); } -TEST(envscan_properties_urls) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_prop_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +/* ObjectScript calls can be introduced by $$$ macros declared in an unchanged + * .inc file. Incremental extraction must therefore use the same repository-wide + * macro context as a fresh build, even when only the consuming .cls changed. + * Return through one cleanup block so a red assertion never leaks its store. */ +typedef enum { + OBJECTSCRIPT_MACRO_CHANGE_CONSUMER, + OBJECTSCRIPT_MACRO_CHANGE_INCLUDE, + OBJECTSCRIPT_MACRO_DELETE_INCLUDE, +} objectscript_macro_change_t; + +static int run_incremental_objectscript_macro_oracle(objectscript_macro_change_t change, + char *err, size_t err_sz) { + int rc = -1; + cbm_config_t *cfg = NULL; + cbm_pipeline_t *pipeline = NULL; + cbm_store_t *store = NULL; + char *project = NULL; + bool initial_call = false; + bool incremental_validate_call = false; + bool incremental_reject_call = false; + char *created = th_mktempdir("cbm_incr_objectscript_macro"); + if (!created) { + snprintf(err, err_sz, "ObjectScript fixture directory creation failed"); + return -1; + } + char root[CBM_PATH_MAX]; + int n = snprintf(root, sizeof(root), "%s", created); + if (n <= 0 || (size_t)n >= sizeof(root)) { + snprintf(err, err_sz, "ObjectScript fixture path overflow"); + th_rmtree(created); + return -1; + } - write_temp_file(tmpdir, "app.properties", - "api.url=https://api.example.com/health\n" - "app.name=myapp\n" - "service.endpoint=https://service.example.com/api\n"); + char include_path[CBM_PATH_MAX]; + char caller_path[CBM_PATH_MAX]; + char utils_path[CBM_PATH_MAX]; + char db_path[CBM_PATH_MAX]; + n = snprintf(include_path, sizeof(include_path), "%s/Macros.inc", root); + if (n <= 0 || (size_t)n >= sizeof(include_path)) { + snprintf(err, err_sz, "ObjectScript include path overflow"); + goto cleanup; + } + n = snprintf(caller_path, sizeof(caller_path), "%s/Caller.cls", root); + if (n <= 0 || (size_t)n >= sizeof(caller_path)) { + snprintf(err, err_sz, "ObjectScript class path overflow"); + goto cleanup; + } + n = snprintf(utils_path, sizeof(utils_path), "%s/Utils.cls", root); + if (n <= 0 || (size_t)n >= sizeof(utils_path)) { + snprintf(err, err_sz, "ObjectScript utility path overflow"); + goto cleanup; + } + n = snprintf(db_path, sizeof(db_path), "%s/graph.db", root); + if (n <= 0 || (size_t)n >= sizeof(db_path)) { + snprintf(err, err_sz, "ObjectScript database path overflow"); + goto cleanup; + } - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + const char *include_initial = + "ROUTINE MyApp.Macros [Type=INC]\n" + "#define MyCheck(%sc) ##class(MyApp.Utils).Validate(%sc)\n"; + const char *include_updated = + "ROUTINE MyApp.Macros [Type=INC]\n" + "#define MyCheck(%sc) ##class(MyApp.Utils).Reject(%sc)\n"; + if (th_write_file(include_path, include_initial) != 0) { + snprintf(err, err_sz, "ObjectScript include write failed"); + goto cleanup; + } + if (th_write_file(utils_path, + "Class MyApp.Utils Extends %RegisteredObject\n" + "{\n" + "ClassMethod Validate(sc As %Status) As %Status\n" + "{\n" + " Quit sc\n" + "}\n" + "ClassMethod Reject(sc As %Status) As %Status\n" + "{\n" + " Quit sc\n" + "}\n" + "}\n") != 0) { + snprintf(err, err_sz, "ObjectScript utility class write failed"); + goto cleanup; + } + const char *caller_initial = + "Include Macros\n" + "Class MyApp.Caller Extends %RegisteredObject\n" + "{\n" + "Method Run(sc As %Status) As %Status\n" + "{\n" + " If $$$MyCheck(sc) { Quit sc }\n" + " Quit $$$OK\n" + "}\n" + "}\n"; + const char *caller_updated = + "Include Macros\n" + "Class MyApp.Caller Extends %RegisteredObject\n" + "{\n" + "Method Run(sc As %Status) As %Status\n" + "{\n" + " Set touched = 1\n" + " If $$$MyCheck(sc) { Quit sc }\n" + " Quit $$$OK\n" + "}\n" + "}\n"; + if (th_write_file(caller_path, caller_initial) != 0) { + snprintf(err, err_sz, "initial ObjectScript class write failed"); + goto cleanup; + } - ASSERT_TRUE(has_binding_value(bindings, count, "https://api.example.com/health")); - ASSERT_TRUE(has_binding_value(bindings, count, "https://service.example.com/api")); + cfg = incremental_test_config(root); + if (!cfg || cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH) != 0) { + snprintf(err, err_sz, "ObjectScript incremental config setup failed"); + goto cleanup; + } + pipeline = cbm_pipeline_new(root, db_path, CBM_MODE_FAST); + if (!pipeline) { + snprintf(err, err_sz, "initial ObjectScript pipeline allocation failed"); + goto cleanup; + } + cbm_pipeline_apply_config(pipeline, cfg); + if (cbm_pipeline_run(pipeline) != 0) { + snprintf(err, err_sz, "initial ObjectScript full publication failed"); + goto cleanup; + } + project = cbm_strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + pipeline = NULL; + if (!project) { + snprintf(err, err_sz, "ObjectScript project allocation failed"); + goto cleanup; + } - th_rmtree(tmpdir); - PASS(); -} + store = cbm_store_open_path(db_path); + if (!store) { + snprintf(err, err_sz, "initial ObjectScript store open failed"); + goto cleanup; + } + initial_call = cross_file_call_exists(store, project, "Run", "Validate"); + cbm_store_close(store); + store = NULL; + if (!initial_call) { + snprintf(err, err_sz, + "full ObjectScript pipeline did not resolve the macro-supplied local call"); + goto cleanup; + } -TEST(envscan_secret_key_exclusion) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_skey_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); + if (change == OBJECTSCRIPT_MACRO_DELETE_INCLUDE) { + if (cbm_unlink(include_path) != 0) { + snprintf(err, err_sz, "ObjectScript include deletion failed"); + goto cleanup; + } + } else { + const char *changed_path = + change == OBJECTSCRIPT_MACRO_CHANGE_INCLUDE ? include_path : caller_path; + const char *changed_source = + change == OBJECTSCRIPT_MACRO_CHANGE_INCLUDE ? include_updated : caller_updated; + if (th_write_file(changed_path, changed_source) != 0) { + snprintf(err, err_sz, "updated ObjectScript fixture write failed"); + goto cleanup; + } + } + pipeline = cbm_pipeline_new(root, db_path, CBM_MODE_FAST); + if (!pipeline) { + snprintf(err, err_sz, "incremental ObjectScript pipeline allocation failed"); + goto cleanup; + } + cbm_pipeline_apply_config(pipeline, cfg); + if (cbm_pipeline_run(pipeline) != 0) { + snprintf(err, err_sz, "incremental ObjectScript publication failed"); + goto cleanup; + } + if (change == OBJECTSCRIPT_MACRO_CHANGE_CONSUMER && + cbm_pipeline_publish_kind(pipeline) != CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT) { + snprintf(err, err_sz, "ObjectScript publication kind=%d reason=%s, expected exact", + cbm_pipeline_publish_kind(pipeline), + cbm_pipeline_publish_reason(pipeline) ? cbm_pipeline_publish_reason(pipeline) + : ""); + goto cleanup; + } + cbm_pipeline_free(pipeline); + pipeline = NULL; - write_temp_file(tmpdir, "Dockerfile", - "FROM node:18\n" - "ENV SECRET_TOKEN=https://api.example.com/api\n" - "ENV API_KEY=https://api.example.com/v1\n" - "ENV PASSWORD=https://auth.example.com/login\n" - "ENV NORMAL_URL=https://api.example.com/orders\n"); + store = cbm_store_open_path(db_path); + if (!store) { + snprintf(err, err_sz, "incremental ObjectScript store open failed"); + goto cleanup; + } + incremental_validate_call = cross_file_call_exists(store, project, "Run", "Validate"); + incremental_reject_call = cross_file_call_exists(store, project, "Run", "Reject"); + cbm_store_close(store); + store = NULL; + if (change == OBJECTSCRIPT_MACRO_CHANGE_CONSUMER && !incremental_validate_call) { + snprintf(err, err_sz, + "incremental ObjectScript extraction lost an unchanged .inc macro call"); + goto cleanup; + } + if (change == OBJECTSCRIPT_MACRO_CHANGE_INCLUDE && + (incremental_validate_call || !incremental_reject_call)) { + snprintf(err, err_sz, + "changed ObjectScript .inc did not invalidate and re-extract its consumer"); + goto cleanup; + } + if (change == OBJECTSCRIPT_MACRO_DELETE_INCLUDE && + (incremental_validate_call || incremental_reject_call)) { + snprintf(err, err_sz, + "deleted ObjectScript .inc did not invalidate and re-extract its consumer"); + goto cleanup; + } - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + root, db_path, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + snprintf(err, err_sz, "%s", + diff_err[0] ? diff_err + : "incremental ObjectScript macro graph differed from fresh rebuild"); + goto cleanup; + } + rc = 0; - /* Secret keys should be excluded */ - ASSERT_TRUE(find_binding_by_key(bindings, count, "SECRET_TOKEN") == NULL); - ASSERT_TRUE(find_binding_by_key(bindings, count, "API_KEY") == NULL); - ASSERT_TRUE(find_binding_by_key(bindings, count, "PASSWORD") == NULL); - /* Normal key should be present */ - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "NORMAL_URL")); +cleanup: + cbm_store_close(store); + cbm_pipeline_free(pipeline); + free(project); + cbm_config_close(cfg); + const char *artifact_dir = getenv("CBM_TEST_ARTIFACT_DIR"); + if (rc != 0 && artifact_dir && artifact_dir[0] != '\0') { + printf(" [incremental-objectscript-artifact] %s\n", root); + } else { + th_rmtree(root); + } + return rc; +} - th_rmtree(tmpdir); +TEST(incremental_objectscript_unchanged_include_macro_matches_fresh_rebuild) { + char err[CBM_SZ_8K] = {0}; + if (run_incremental_objectscript_macro_oracle(OBJECTSCRIPT_MACRO_CHANGE_CONSUMER, err, + sizeof(err)) != 0) { + FAIL(err[0] ? err : "ObjectScript incremental macro oracle failed"); + } PASS(); } -TEST(envscan_secret_value_exclusion) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sval_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(incremental_objectscript_changed_include_reextracts_consumers) { + char err[CBM_SZ_8K] = {0}; + if (run_incremental_objectscript_macro_oracle(OBJECTSCRIPT_MACRO_CHANGE_INCLUDE, err, + sizeof(err)) != 0) { + FAIL(err[0] ? err : "ObjectScript include invalidation oracle failed"); + } + PASS(); +} - write_temp_file( - tmpdir, "deploy.sh", - "#!/bin/bash\n" - "export GH_URL=\"https://ghp_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE@github.com/repo\"\n" - "export NORMAL_ENDPOINT=\"https://api.example.com/orders\"\n"); +TEST(incremental_objectscript_deleted_include_reextracts_consumers) { + char err[CBM_SZ_8K] = {0}; + if (run_incremental_objectscript_macro_oracle(OBJECTSCRIPT_MACRO_DELETE_INCLUDE, err, + sizeof(err)) != 0) { + FAIL(err[0] ? err : "ObjectScript include deletion invalidation oracle failed"); + } + PASS(); +} - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); +TEST(incremental_mixed_python_rust_edits_match_fresh_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* ghp_ token URL should be excluded */ - ASSERT_TRUE(find_binding_by_key(bindings, count, "GH_URL") == NULL); - /* Normal URL should be present */ - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "NORMAL_ENDPOINT")); + char rust_path[CBM_PATH_MAX]; + char python_path[CBM_PATH_MAX]; + int n = snprintf(rust_path, sizeof(rust_path), "%s/native_bridge.rs", g_incr_tmpdir); + ASSERT(n > 0 && (size_t)n < sizeof(rust_path)); + n = snprintf(python_path, sizeof(python_path), "%s/entrypoint.py", g_incr_tmpdir); + ASSERT(n > 0 && (size_t)n < sizeof(python_path)); + ASSERT_EQ(th_write_file(rust_path, "#[pyfunction]\nfn native_execute() -> i32 { 1 }\n"), 0); + ASSERT_EQ(th_write_file(python_path, "def cli_main():\n" + " from package._native import native_execute\n" + " return native_execute()\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - th_rmtree(tmpdir); + ASSERT_EQ(th_write_file(python_path, "def python_helper():\n" + " return 2\n\n" + "def cli_main():\n" + " from package._native import native_execute\n" + " return native_execute() + python_helper()\n"), + 0); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "mixed Python/Rust Python edit differed from fresh rebuild"); + } + + ASSERT_EQ(th_write_file(rust_path, "#[pyfunction]\nfn native_execute() -> i32 { 2 }\n" + "#[pyfunction]\nfn native_extra() -> i32 { 3 }\n"), + 0); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "scoped_lsp_gap"); + cbm_pipeline_free(p); + + diff_err[0] = '\0'; + diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "mixed Python/Rust Rust edit differed from fresh rebuild"); + } + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_secret_file_exclusion) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_sfile_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(incremental_mixed_rust_typescript_javascript_matches_fresh_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* Secret file should be skipped */ - write_temp_file(tmpdir, "credentials.sh", - "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); - /* Normal file should be scanned */ - write_temp_file(tmpdir, "setup.sh", - "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + char rust_path[CBM_PATH_MAX]; + char bridge_path[CBM_PATH_MAX]; + char caller_path[CBM_PATH_MAX]; + int n = snprintf(rust_path, sizeof(rust_path), "%s/native_core.rs", g_incr_tmpdir); + ASSERT(n > 0 && (size_t)n < sizeof(rust_path)); + n = snprintf(bridge_path, sizeof(bridge_path), "%s/bridge.ts", g_incr_tmpdir); + ASSERT(n > 0 && (size_t)n < sizeof(bridge_path)); + n = snprintf(caller_path, sizeof(caller_path), "%s/caller.js", g_incr_tmpdir); + ASSERT(n > 0 && (size_t)n < sizeof(caller_path)); + ASSERT_EQ(th_write_file(rust_path, "pub fn native_score() -> i32 { 1 }\n"), 0); + ASSERT_EQ( + th_write_file(bridge_path, "export function bridgeOperation(): number { return 1; }\n"), 0); + ASSERT_EQ(th_write_file(caller_path, + "import { bridgeOperation } from './bridge';\n" + "export function javascriptCaller() { return bridgeOperation(); }\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + ASSERT_TRUE(cross_file_call_exists(store, project, "javascriptCaller", "bridgeOperation")); + cbm_store_close(store); - /* Should find binding from setup.sh but not credentials.sh */ - int from_credentials = 0; - int from_setup = 0; - for (int i = 0; i < count; i++) { - if (strcmp(bindings[i].file_path, "credentials.sh") == 0) - from_credentials = 1; - if (strcmp(bindings[i].file_path, "setup.sh") == 0) - from_setup = 1; + ASSERT_EQ( + th_write_file(bridge_path, + "export function bridgeOperation(): number { return 2; }\n" + "export function bridgeExtra(): number { return bridgeOperation(); }\n"), + 0); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_FULL); + ASSERT_STR_EQ(cbm_pipeline_publish_reason(p), "scoped_lsp_gap"); + cbm_pipeline_free(p); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err + : "mixed Rust/TypeScript/JavaScript edit differed from fresh rebuild"); } - ASSERT_EQ(from_credentials, 0); - ASSERT_EQ(from_setup, 1); - th_rmtree(tmpdir); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_skips_ignored_dirs) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_ign_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); - - /* File inside .git should be skipped */ - char gitdir[512]; - snprintf(gitdir, sizeof(gitdir), "%s/.git", tmpdir); - cbm_mkdir(gitdir); - write_temp_file(tmpdir, ".git/config.sh", - "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); - - /* File inside node_modules should be skipped */ - char nmdir[512]; - snprintf(nmdir, sizeof(nmdir), "%s/node_modules", tmpdir); - cbm_mkdir(nmdir); - char nmpkg[512]; - snprintf(nmpkg, sizeof(nmpkg), "%s/node_modules/pkg", tmpdir); - cbm_mkdir(nmpkg); - write_temp_file(tmpdir, "node_modules/pkg/config.sh", - "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); +TEST(incremental_exact_python_receiver_type_gap_matches_full_rebuild) { + enum { PIPELINE_EXACT_ONE_PATH = 1 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* File at root level should be scanned */ - write_temp_file(tmpdir, "deploy.sh", - "#!/bin/bash\nexport API_URL=\"https://api.example.com/v1\"\n"); + char provider_path[CBM_PATH_MAX]; + char service_path[CBM_PATH_MAX]; + int n = snprintf(provider_path, sizeof(provider_path), "%s/provider.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(provider_path)); + n = snprintf(service_path, sizeof(service_path), "%s/service.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(service_path)); + + ASSERT_EQ(th_write_file(provider_path, + "class Logger:\n" + " def log(self, msg):\n" + " return msg\n\n" + "class OtherLogger:\n" + " def log(self, msg):\n" + " return msg\n"), + 0); + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('old')\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + char cap_value[CBM_SZ_32]; + n = snprintf(cap_value, sizeof(cap_value), "%d", PIPELINE_EXACT_ONE_PATH); + ASSERT(n >= 0 && (size_t)n < sizeof(cap_value)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_CHANGED_PATHS, cap_value), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, cap_value), + 0); + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('new')\n\n" + "def scoped_gap_marker():\n" + " return Service().run()\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.exact.skip reason=scoped_lsp_gap") == NULL); + ASSERT(strstr(logs, "msg=incremental.fallback reason=scoped_lsp_gap") == NULL); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + cbm_pipeline_free(p); - int from_git = 0, from_nm = 0, from_root = 0; - for (int i = 0; i < count; i++) { - if (strncmp(bindings[i].file_path, ".git/", 5) == 0) - from_git = 1; - if (strncmp(bindings[i].file_path, "node_modules/", 13) == 0) - from_nm = 1; - if (strcmp(bindings[i].file_path, "deploy.sh") == 0) - from_root = 1; + char *source_qn = cbm_pipeline_fqn_compute(project, "service.py", "Service.run"); + char *target_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger.log"); + ASSERT_NOT_NULL(source_qn); + ASSERT_NOT_NULL(target_qn); + ASSERT_TRUE( + pipeline_store_has_edge_between_qns(g_incr_dbpath, project, source_qn, "CALLS", target_qn)); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err + : "Python receiver-type exact reindex differed from fresh rebuild"); } - ASSERT_EQ(from_git, 0); - ASSERT_EQ(from_nm, 0); - ASSERT_EQ(from_root, 1); + ASSERT_EQ(diff_rc, 0); - th_rmtree(tmpdir); + free(source_qn); + free(target_qn); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -TEST(envscan_non_url_values_skipped) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_nurl_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(pipeline_persisted_python_defs_feed_scoped_cross_lsp) { + enum { PIPELINE_PERSISTED_SCOPE_CAP = CBM_SZ_8 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - write_temp_file(tmpdir, "Dockerfile", - "FROM python:3.9\n" - "ENV APP_NAME=my-service\n" - "ENV PORT=8080\n" - "ENV DEBUG=true\n" - "ENV LOG_LEVEL=info\n"); - write_temp_file(tmpdir, "config.sh", - "#!/bin/bash\n" - "export REGION=\"us-east-1\"\n" - "export COUNT=42\n"); + char provider_path[CBM_PATH_MAX]; + char service_path[CBM_PATH_MAX]; + int n = snprintf(provider_path, sizeof(provider_path), "%s/provider.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(provider_path)); + n = snprintf(service_path, sizeof(service_path), "%s/service.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(service_path)); + + ASSERT_EQ(th_write_file(provider_path, + "class Logger:\n" + " def log(self, msg):\n" + " return msg\n\n" + "class OtherLogger:\n" + " def log(self, msg):\n" + " return msg\n"), + 0); + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('old')\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); + const char *changed_source = + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('new')\n"; + CBMFileResult *changed_result = cbm_extract_file_with_options( + changed_source, (int)strlen(changed_source), CBM_LANG_PYTHON, project, "service.py", + CBM_EXTRACT_BUDGET, NULL, NULL, false); + ASSERT_NOT_NULL(changed_result); + + cbm_file_info_t changed_file = { + .path = service_path, + .rel_path = "service.py", + .language = CBM_LANG_PYTHON, + }; + CBMFileResult *changed_cache[] = {changed_result}; + char *changed_modules[] = {NULL}; + int own_def_count = 0; + CBMLSPDef *own_defs = cbm_pxc_collect_all_defs(changed_cache, &changed_file, CBM_ALLOC_ONE, + project, changed_modules, &own_def_count); + ASSERT_NOT_NULL(own_defs); + ASSERT_GT(own_def_count, 0); + + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + + char *import_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger"); + char *provider_log_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger.log"); + char *other_log_qn = cbm_pipeline_fqn_compute(project, "provider.py", "OtherLogger.log"); + ASSERT_NOT_NULL(import_qn); + ASSERT_NOT_NULL(provider_log_qn); + ASSERT_NOT_NULL(other_log_qn); + + const char *scope_inputs[] = {import_qn}; + char **candidate_qns = NULL; + int candidate_qn_count = 0; + bool candidate_truncated = true; + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + store, project, scope_inputs, CBM_ALLOC_ONE, PIPELINE_PERSISTED_SCOPE_CAP, + &candidate_qns, &candidate_qn_count, &candidate_truncated), + CBM_STORE_OK); + ASSERT_FALSE(candidate_truncated); + ASSERT_EQ(candidate_qn_count, PAIR_LEN); + ASSERT_TRUE(pipeline_text_array_contains(candidate_qns, candidate_qn_count, import_qn)); + ASSERT_TRUE(pipeline_text_array_contains(candidate_qns, candidate_qn_count, provider_log_qn)); + ASSERT_FALSE(pipeline_text_array_contains(candidate_qns, candidate_qn_count, other_log_qn)); + + cbm_node_t *provider_nodes = NULL; + int provider_node_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qns(store, project, (const char **)candidate_qns, + candidate_qn_count, &provider_nodes, + &provider_node_count), + CBM_STORE_OK); + ASSERT_EQ(provider_node_count, PAIR_LEN); + + CBMArena persisted_arena; + cbm_arena_init(&persisted_arena); + CBMLSPDef persisted_defs[PIPELINE_PERSISTED_SCOPE_CAP]; + memset(persisted_defs, 0, sizeof(persisted_defs)); + int persisted_count = 0; + for (int i = 0; i < provider_node_count; i++) { + ASSERT_EQ(cbm_pxc_build_lsp_def_from_node(&persisted_arena, &provider_nodes[i], + CBM_LANG_PYTHON, + &persisted_defs[persisted_count]), + 0); + persisted_count++; + } + cbm_store_free_nodes(provider_nodes, provider_node_count); + cbm_store_close(store); + + int total_def_count = own_def_count + persisted_count; + CBMLSPDef *all_defs = calloc((size_t)total_def_count, sizeof(*all_defs)); + ASSERT_NOT_NULL(all_defs); + memcpy(all_defs, own_defs, (size_t)own_def_count * sizeof(*all_defs)); + memcpy(all_defs + own_def_count, persisted_defs, + (size_t)persisted_count * sizeof(*all_defs)); + + char *module_qn = cbm_pipeline_fqn_module(project, "service.py"); + ASSERT_NOT_NULL(module_qn); + const char *imp_names[] = {"Logger"}; + const char *imp_qns[] = {import_qn}; + CBMArena out_arena; + cbm_arena_init(&out_arena); + CBMResolvedCallArray out = {0}; + cbm_run_py_lsp_cross(&out_arena, changed_source, (int)strlen(changed_source), module_qn, + all_defs, total_def_count, imp_names, imp_qns, CBM_ALLOC_ONE, + changed_result->cached_tree, &out); + + ASSERT_TRUE(pipeline_resolved_call_contains(&out, "Service.run", "provider.Logger.log")); + + cbm_arena_destroy(&out_arena); + free(import_qn); + free(provider_log_qn); + free(other_log_qn); + free(module_qn); + free(all_defs); + for (int i = 0; i < candidate_qn_count; i++) { + free(candidate_qns[i]); + } + free(candidate_qns); + cbm_arena_destroy(&persisted_arena); + free(own_defs); + free(changed_modules[0]); + cbm_free_result(changed_result); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); +} - ASSERT_EQ(count, 0); +TEST(pipeline_store_backed_lsp_cross_uses_import_scope_defs) { + enum { PIPELINE_STORE_BACKED_LSP_SCOPE_CAP = CBM_SZ_8 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - th_rmtree(tmpdir); + char provider_path[CBM_PATH_MAX]; + char service_path[CBM_PATH_MAX]; + int n = snprintf(provider_path, sizeof(provider_path), "%s/provider.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(provider_path)); + n = snprintf(service_path, sizeof(service_path), "%s/service.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(service_path)); + + ASSERT_EQ(th_write_file(provider_path, + "class Logger:\n" + " def log(self, msg):\n" + " return msg\n\n" + "class OtherLogger:\n" + " def log(self, msg):\n" + " return msg\n"), + 0); + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('old')\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('new')\n"), + 0); + + const char *changed_paths[] = {"service.py"}; + cbm_file_info_t all_files[] = { + {.path = provider_path, .rel_path = "provider.py", .language = CBM_LANG_PYTHON}, + {.path = service_path, .rel_path = "service.py", .language = CBM_LANG_PYTHON}, + }; + cbm_file_info_t changed_file = { + .path = service_path, + .rel_path = "service.py", + .language = CBM_LANG_PYTHON, + }; + CBMFileResult *result_cache[CBM_ALLOC_ONE] = {NULL}; + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = cbm_gbuf_new(project, g_incr_tmpdir); + cbm_registry_t *registry = cbm_registry_new(); + ASSERT_NOT_NULL(scratch); + ASSERT_NOT_NULL(registry); + ASSERT_EQ(cbm_pipeline_seed_file_delta_scratch_from_store( + store, scratch, registry, project, changed_paths, CBM_ALLOC_ONE), + CBM_STORE_OK); + const char *structure_root_qn = pipeline_exact_scratch_structure_root_qn(scratch, project); + ASSERT_EQ(cbm_pipeline_ensure_file_structure(scratch, project, structure_root_qn, + changed_file.rel_path, NULL), + 0); + + const double pipeline_default_threshold = 0.0; + cbm_pipeline_ctx_t ctx = {.project_name = project, + .repo_path = g_incr_tmpdir, + .gbuf = scratch, + .registry = registry, + .cancelled = &cancelled, + .mode = CBM_MODE_FAST, + .similarity_threshold = pipeline_default_threshold, + .httplink_min_confidence = pipeline_default_threshold, + .semantic_threshold = pipeline_default_threshold, + .githistory_min_coupling = pipeline_default_threshold, + .lsp_confidence_floor = pipeline_default_threshold, + .result_cache = result_cache, + .store_backed_node_lookup = store, + .store_backed_changed_paths = changed_paths, + .store_backed_changed_path_count = CBM_ALLOC_ONE, + .store_backed_all_files = all_files, + .store_backed_all_file_count = + (int)(sizeof(all_files) / sizeof(all_files[0])), + .store_backed_lsp_scope_cap = + PIPELINE_STORE_BACKED_LSP_SCOPE_CAP}; + + ASSERT_EQ(cbm_pipeline_pass_definitions(&ctx, &changed_file, CBM_ALLOC_ONE), 0); + ASSERT_NOT_NULL(result_cache[0]); + ASSERT_EQ(cbm_pipeline_pass_lsp_cross(&ctx, &changed_file, CBM_ALLOC_ONE, result_cache), 0); + ASSERT_TRUE(pipeline_resolved_call_contains(&result_cache[0]->resolved_calls, "Service.run", + "provider.Logger.log")); + ASSERT_FALSE(pipeline_resolved_call_contains(&result_cache[0]->resolved_calls, "Service.run", + "provider.OtherLogger.log")); + + if (ctx.seq_cross_arena_live) { + cbm_arena_destroy(&ctx.seq_cross_arena); + ctx.seq_cross_arena_live = false; + } + cbm_free_result(result_cache[0]); + cbm_registry_free(registry); + cbm_gbuf_free(scratch); + cbm_store_close(store); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── Discovery-exclusion plumbing in auxiliary repo walks (#792) ── */ +TEST(incremental_exact_scratch_store_backed_lsp_matches_fresh_rebuild) { + enum { PIPELINE_STORE_BACKED_EXACT_SCOPE_CAP = CBM_SZ_8 }; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -/* Boundary semantics of the shared exclusion predicate: anchored at the - * repo root, matches the excluded dir itself and its subtree, but never - * sibling names sharing a prefix. Regression guard for issue #792. */ -TEST(pipeline_relpath_excluded_boundary) { - char *excluded[] = {(char *)"vendor_big", (char *)"packages/big"}; + char provider_path[CBM_PATH_MAX]; + char service_path[CBM_PATH_MAX]; + int n = snprintf(provider_path, sizeof(provider_path), "%s/provider.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(provider_path)); + n = snprintf(service_path, sizeof(service_path), "%s/service.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(service_path)); + + ASSERT_EQ(th_write_file(provider_path, + "class Logger:\n" + " def log(self, msg):\n" + " return msg\n\n" + "class OtherLogger:\n" + " def log(self, msg):\n" + " return msg\n"), + 0); + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('old')\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char pass_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(p, pass_fingerprint, + sizeof(pass_fingerprint)), + CBM_STORE_OK); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* Exact match and subtree paths are excluded. */ - ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("vendor_big", excluded, 2)); - ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("vendor_big/lib/package.json", excluded, 2)); - ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("packages/big", excluded, 2)); - ASSERT_TRUE(cbm_pipeline_relpath_is_excluded("packages/big/src/x.ts", excluded, 2)); + ASSERT_EQ(th_write_file(service_path, + "from provider import Logger\n\n" + "class Service:\n" + " def __init__(self):\n" + " self.logger: Logger = Logger()\n\n" + " def run(self):\n" + " return self.logger.log('new')\n"), + 0); + + cbm_file_info_t all_files[] = { + {.path = provider_path, .rel_path = "provider.py", .language = CBM_LANG_PYTHON}, + {.path = service_path, .rel_path = "service.py", .language = CBM_LANG_PYTHON}, + }; + cbm_file_info_t changed = { + .path = service_path, + .rel_path = "service.py", + .language = CBM_LANG_PYTHON, + }; - /* Sibling names sharing the prefix are NOT excluded ('/'-boundary). */ - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_bigger", excluded, 2)); - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor", excluded, 2)); - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("packages/bigger/x.ts", excluded, 2)); + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files_ex( + store, g_incr_tmpdir, project, &changed, CBM_ALLOC_ONE, all_files, + (int)(sizeof(all_files) / sizeof(all_files[0])), + PIPELINE_STORE_BACKED_EXACT_SCOPE_CAP, &scratch, &delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata_with_fingerprint(&delta, &changed, + pass_fingerprint), + CBM_STORE_OK); - /* Exclusions are root-anchored prefixes, not substring matches. */ - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("src/vendor_big/x.c", excluded, 2)); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(store, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, CBM_PIPELINE_COMPAT_GENERATION); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, generation), CBM_STORE_OK); + + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(store, deltas, CBM_ALLOC_ONE, + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS, + &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(store); + + char *source_qn = cbm_pipeline_fqn_compute(project, "service.py", "Service.run"); + char *target_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger.log"); + ASSERT_NOT_NULL(source_qn); + ASSERT_NOT_NULL(target_qn); + ASSERT_TRUE( + pipeline_store_has_edge_between_qns(g_incr_dbpath, project, source_qn, "CALLS", target_qn)); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "store-backed Python exact delta differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); - /* NULL / empty safety. */ - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded(NULL, excluded, 2)); - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("", excluded, 2)); - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", NULL, 0)); - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", excluded, 0)); - char *with_empty[] = {(char *)""}; - ASSERT_FALSE(cbm_pipeline_relpath_is_excluded("vendor_big", with_empty, 1)); + free(source_qn); + free(target_qn); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* Helper: does the entries array contain a package with this name? */ -static int pkg_entries_has_name(const cbm_pkg_entries_t *e, const char *name) { - for (int i = 0; i < e->count; i++) { - if (e->items[i].pkg_name && strcmp(e->items[i].pkg_name, name) == 0) - return 1; +TEST(incremental_exact_scratch_field_hint_materializes_store_target) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - return 0; -} -/* Helper: return the entry_rel registered for `name`, or NULL. */ -static const char *pkg_entries_entry_for(const cbm_pkg_entries_t *e, const char *name) { - for (int i = 0; i < e->count; i++) { - if (e->items[i].pkg_name && strcmp(e->items[i].pkg_name, name) == 0) - return e->items[i].entry_rel; - } - return NULL; -} + char docs_dir[CBM_PATH_MAX]; + char custom_dir[CBM_PATH_MAX]; + char fastapi_dir[CBM_PATH_MAX]; + char routing_path[CBM_PATH_MAX]; + char tutorial_path[CBM_PATH_MAX]; + char payload_path[CBM_PATH_MAX]; + int n = snprintf(docs_dir, sizeof(docs_dir), "%s/docs_src", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(docs_dir)); + n = snprintf(custom_dir, sizeof(custom_dir), "%s/docs_src/custom_request_and_route", + g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(custom_dir)); + n = snprintf(fastapi_dir, sizeof(fastapi_dir), "%s/fastapi", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(fastapi_dir)); + ASSERT_EQ(th_mkdir_p(docs_dir), 0); + ASSERT_EQ(th_mkdir_p(custom_dir), 0); + ASSERT_EQ(th_mkdir_p(fastapi_dir), 0); + + n = snprintf(routing_path, sizeof(routing_path), "%s/fastapi/routing.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(routing_path)); + n = snprintf(tutorial_path, sizeof(tutorial_path), + "%s/docs_src/custom_request_and_route/tutorial001.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(tutorial_path)); + n = snprintf(payload_path, sizeof(payload_path), "%s/payloads.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(payload_path)); + + ASSERT_EQ(th_write_file(tutorial_path, + "class GzipRequest:\n" + " def body(self):\n" + " return b'gzip'\n"), + 0); + ASSERT_EQ(th_write_file(payload_path, + "class Payload:\n" + " def body(self):\n" + " return b'payload'\n"), + 0); + ASSERT_EQ(th_write_file(routing_path, + "def route(request):\n" + " return request.body()\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char pass_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(p, pass_fingerprint, + sizeof(pass_fingerprint)), + CBM_STORE_OK); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -/* ── SwiftPM Package.swift manifest resolution (issue #551 item 1) ── - * - * parse_package_swift is a literal pattern-extractor (mirrors - * parse_cargo_toml), not a Swift evaluator. These call cbm_pkgmap_try_parse - * directly, covering the RED categories the maintainer asked for (local - * path deps, remote identities, products not aliasing, targets, target-name - * deps, literal + computed `path:`, and comment/string false positives) - * plus fail-closed ambiguous-name cases. See pipeline_swift_cross_package_import - * above for the full end-to-end proof. */ + ASSERT_EQ(th_write_file(routing_path, + "def route(request):\n" + " value = request.body()\n" + " return value\n"), + 0); -TEST(pkgmap_swift_targets_registers_module) { - static const char src[] = - "// swift-tools-version:5.9\n" - "import PackageDescription\n" - "let package = Package(\n" - " name: \"Core\",\n" - " targets: [.target(name: \"Core\", dependencies: [])]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); - ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core"); - cbm_pkg_entries_free(&entries); - PASS(); -} + cbm_file_info_t changed = { + .path = routing_path, + .rel_path = "fastapi/routing.py", + .language = CBM_LANG_PYTHON, + }; + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files(store, g_incr_tmpdir, project, + &changed, CBM_ALLOC_ONE, &scratch, + &delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata_with_fingerprint(&delta, &changed, + pass_fingerprint), + CBM_STORE_OK); -/* Products deliberately do NOT self-register a separate alias: a product - * name is not generally an importable module (SwiftPM lets it alias - * multiple targets, or none sharing its own name), so only the underlying - * target -- under its OWN name -- registers. */ -TEST(pkgmap_swift_products_do_not_register_alias) { - static const char src[] = - "let package = Package(\n" - " name: \"Core\",\n" - " products: [.library(name: \"CoreKit\", targets: [\"CoreImpl\"])],\n" - " targets: [.target(name: \"CoreImpl\", dependencies: [])]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_FALSE(pkg_entries_has_name(&entries, "CoreKit")); - ASSERT_TRUE(pkg_entries_has_name(&entries, "CoreImpl")); - ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "CoreImpl"), "Core/Sources/CoreImpl"); - ASSERT_EQ(entries.count, 1); - cbm_pkg_entries_free(&entries); - PASS(); -} + char *source_qn = cbm_pipeline_fqn_compute(project, "fastapi/routing.py", "route"); + char *target_qn = + cbm_pipeline_fqn_compute(project, "docs_src/custom_request_and_route/tutorial001.py", + "GzipRequest.body"); + ASSERT_NOT_NULL(source_qn); + ASSERT_NOT_NULL(target_qn); + ASSERT_EQ(pipeline_file_delta_count_call_edge(&delta, source_qn, target_qn, "request.body", + "field_type_hint"), + 1); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(store, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, CBM_PIPELINE_COMPAT_GENERATION); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, generation), CBM_STORE_OK); + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(store, deltas, CBM_ALLOC_ONE, + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS, + &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(store); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "field-hint exact delta differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); -/* Regression: a target whose `name:` is the LAST argument, immediately - * followed by the call's own closing ')' with no trailing comma, must still - * register. swift_quoted_literal's terminator check used to compare against - * `end` with a strict '<', but every caller passes the wrapping call's own - * ')' position AS `end` -- so the literal's closing quote landing exactly - * on that boundary was wrongly rejected as "unterminated". Every other - * fixture in this file happens to follow `name:` with `dependencies:` or a - * comma, so this specific shape was previously untested and unnoticed. */ -TEST(pkgmap_swift_target_name_immediately_before_close_paren) { - static const char src[] = - "let package = Package(\n" - " name: \"Core\",\n" - " targets: [.target(name: \"Core\")]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); - ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core"); - ASSERT_EQ(entries.count, 1); - cbm_pkg_entries_free(&entries); + free(source_qn); + free(target_qn); + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* A literal `path:` argument overrides the Sources/ convention. */ -TEST(pkgmap_swift_target_honors_literal_path) { - static const char src[] = - "let package = Package(\n" - " name: \"Core\",\n" - " targets: [.target(name: \"Core\", path: \"Vendor/CoreLegacy\")]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); - ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Vendor/CoreLegacy"); - cbm_pkg_entries_free(&entries); - PASS(); -} +TEST(incremental_exact_scratch_python_package_matches_fresh_rebuild) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -/* A `path:` argument that IS present but not a bare literal (computed) is - * unknowable -- SwiftPM would not use the Sources/ convention here, - * so guessing it anyway would mint a location likely to be wrong. Skip the - * target entirely (fail closed), even though its `name:` is a valid - * literal. */ -TEST(pkgmap_swift_target_computed_path_fails_closed) { - static const char src[] = - "let customPath = computePath()\n" - "let package = Package(\n" - " name: \"Core\",\n" - " targets: [.target(name: \"Core\", path: customPath)]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_EQ(entries.count, 0); - cbm_pkg_entries_free(&entries); + char fastapi_dir[CBM_PATH_MAX]; + int n = snprintf(fastapi_dir, sizeof(fastapi_dir), "%s/fastapi", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(fastapi_dir)); + ASSERT_EQ(cbm_mkdir(fastapi_dir), 0); + + char init_path[CBM_PATH_MAX]; + char exceptions_path[CBM_PATH_MAX]; + char datastructures_path[CBM_PATH_MAX]; + char routing_path[CBM_PATH_MAX]; + n = snprintf(init_path, sizeof(init_path), "%s/fastapi/__init__.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(init_path)); + n = snprintf(exceptions_path, sizeof(exceptions_path), "%s/fastapi/exceptions.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(exceptions_path)); + n = snprintf(datastructures_path, sizeof(datastructures_path), + "%s/fastapi/datastructures.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(datastructures_path)); + n = snprintf(routing_path, sizeof(routing_path), "%s/fastapi/routing.py", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(routing_path)); + + ASSERT_EQ(th_write_file(init_path, + "from .datastructures import DefaultPlaceholder\n" + "from .exceptions import HTTPException\n"), + 0); + ASSERT_EQ(th_write_file(exceptions_path, + "class HTTPException(Exception):\n" + " pass\n"), + 0); + ASSERT_EQ(th_write_file(datastructures_path, + "class DefaultPlaceholder:\n" + " pass\n"), + 0); + ASSERT_EQ(th_write_file(routing_path, + "from fastapi.datastructures import DefaultPlaceholder\n" + "from fastapi.exceptions import HTTPException\n\n" + "def serialize_response(field=None, response_content=None):\n" + " return response_content\n\n" + "def route_handler(response_field, raw_response):\n" + " marker = DefaultPlaceholder()\n" + " if marker:\n" + " raise HTTPException()\n" + " return raw_response\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char pass_fingerprint[CBM_SZ_256]; + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint(p, pass_fingerprint, + sizeof(pass_fingerprint)), + CBM_STORE_OK); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + ASSERT_EQ(th_write_file(routing_path, + "from fastapi.datastructures import DefaultPlaceholder\n" + "from fastapi.exceptions import HTTPException\n\n" + "def serialize_response(field=None, response_content=None, include=None, " + "exclude=None, by_alias=True, exclude_unset=False, " + "exclude_defaults=False, exclude_none=False):\n" + " return response_content\n\n" + "def route_handler(response_field, raw_response, response_model_include, " + "response_model_exclude, response_model_by_alias, " + "response_model_exclude_unset, response_model_exclude_defaults, " + "response_model_exclude_none):\n" + " marker = DefaultPlaceholder()\n" + " if not marker:\n" + " return raw_response\n" + " return serialize_response(field=response_field, " + "response_content=raw_response, include=response_model_include, " + "exclude=response_model_exclude, by_alias=response_model_by_alias, " + "exclude_unset=response_model_exclude_unset, " + "exclude_defaults=response_model_exclude_defaults, " + "exclude_none=response_model_exclude_none)\n"), + 0); + + cbm_file_info_t changed = { + .path = routing_path, + .rel_path = "fastapi/routing.py", + .language = CBM_LANG_PYTHON, + }; + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_gbuf_t *scratch = NULL; + cbm_pipeline_file_delta_t delta = {0}; + ASSERT_EQ(pipeline_build_exact_scratch_for_changed_files(store, g_incr_tmpdir, project, + &changed, CBM_ALLOC_ONE, &scratch, + &delta), + CBM_STORE_OK); + ASSERT_EQ(cbm_pipeline_attach_file_delta_metadata_with_fingerprint(&delta, &changed, + pass_fingerprint), + CBM_STORE_OK); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(store, project, NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_GT(generation, CBM_PIPELINE_COMPAT_GENERATION); + ASSERT_EQ(cbm_pipeline_file_delta_stamp_generation(&delta, generation), CBM_STORE_OK); + const cbm_pipeline_file_delta_t *deltas[] = {&delta}; + cbm_pipeline_file_delta_plan_t plan = {0}; + ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(store, deltas, CBM_ALLOC_ONE, + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS, + &plan), + CBM_STORE_OK); + ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE); + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(store); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = pipeline_compare_current_db_to_fresh_fast_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, cfg, diff_err, sizeof(diff_err)); + if (diff_rc != 0) { + FAIL(diff_err[0] ? diff_err : "Python exact scratch delta differed from fresh rebuild"); + } + ASSERT_EQ(diff_rc, 0); + + cbm_pipeline_file_delta_free(&delta); + cbm_gbuf_free(scratch); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* A `.target(` spelled inside a `//` line comment, a nesting-aware - * slash-star block comment, or a string literal must never be mistaken for a live - * declaration -- the bug a raw strstr scan cannot avoid. Only the one real - * target registers. */ -TEST(pkgmap_swift_target_in_comment_or_string_not_registered) { - static const char src[] = - "// .target(name: \"Decoy\")\n" - "/* outer /* nested */ still a comment: .target(name: \"NestedDecoy\") */\n" - "let manifestSnippet = \".target(name: \\\"StringDecoy\\\")\"\n" - "let package = Package(\n" - " name: \"App\",\n" - " targets: [.target(name: \"App\", dependencies: [])]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_TRUE(pkg_entries_has_name(&entries, "App")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "Decoy")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "NestedDecoy")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "StringDecoy")); - ASSERT_EQ(entries.count, 1); - cbm_pkg_entries_free(&entries); +TEST(incremental_overlay_first_preserves_inbound_edges_past_exact_frontier_cap) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + ASSERT_EQ(write_incremental_frontier_fixture(CBM_ALLOC_ONE), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_overlay_call_connected(g_incr_dbpath, project, "CallerA", "Leaf")); + + ASSERT_EQ(write_incremental_leaf_file(CBM_SZ_2), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=1") != NULL); + ASSERT(strstr(logs, "msg=incremental.exact.fallback reason=frontier_too_large") == NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + ASSERT(!cbm_pipeline_graph_changed(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, 1); + ASSERT_EQ(stats.published_paths, 1); + cbm_pipeline_free(p); + + ASSERT(pipeline_store_overlay_call_connected(g_incr_dbpath, project, "CallerA", "Leaf")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* Local path + remote url dependencies (`.package(path:)` / `.package(url:)`) - * mint NO entries of their own -- mirroring package.json/Cargo.toml, only a - * manifest's OWN products/targets self-register. A local sibling's name is - * produced by ITS OWN Package.swift when the repo-wide walk reaches it - * (see repro_issue408.c's JS-workspace analog); a remote dependency has no - * local path to point at, so nothing is minted (fail-closed). */ -TEST(pkgmap_swift_dependencies_do_not_leak_entries) { - static const char src[] = - "let package = Package(\n" - " name: \"App\",\n" - " dependencies: [\n" - " .package(path: \"../Core\"),\n" - " .package(url: \"https://github.com/example/RemoteKit.git\", from: \"1.0.0\")\n" - " ],\n" - " targets: [.target(name: \"App\", dependencies: [])]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_TRUE(pkg_entries_has_name(&entries, "App")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "Core")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "RemoteKit")); - ASSERT_EQ(entries.count, 1); - cbm_pkg_entries_free(&entries); +TEST(incremental_overlay_publish_delete_keeps_canonical_base_visible) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 1\n}\n"), + 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Leaf")); + ASSERT(pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "leaf.go", "Leaf")); + + ASSERT_EQ(cbm_unlink(path), 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.overlay.done files=1") != NULL); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + ASSERT(!cbm_pipeline_graph_changed(p)); + cbm_pipeline_exact_delta_stats_t stats = cbm_pipeline_exact_delta_stats(p); + ASSERT_EQ(stats.changed_paths, 1); + ASSERT_EQ(stats.affected_paths, 1); + ASSERT_EQ(stats.published_paths, 1); + cbm_pipeline_free(p); + + ASSERT_EQ(pipeline_store_generation_status_count(g_incr_dbpath, project, + CBM_STORE_INDEX_STATUS_RESERVED), + 0); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Leaf")); + ASSERT(!pipeline_store_overlay_file_has_function(g_incr_dbpath, project, "leaf.go", "Leaf")); + int64_t leaf_generation = 0; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "leaf.go", + &leaf_generation), + CBM_STORE_OK); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* Only App itself (the declaring target) registers -- a bare same-package - * target-name dependency ("Core") and a cross-package product dependency - * (Utils/UtilsPkg) name OTHER modules, not this manifest's own - * products/targets, so neither mints an entry. */ -TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry) { - static const char src[] = - "let package = Package(\n" - " name: \"App\",\n" - " targets: [.target(name: \"App\", dependencies: [\n" - " \"Core\",\n" - " .product(name: \"Utils\", package: \"UtilsPkg\")\n" - " ])]\n" - ")\n"; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, - (int)strlen(src), &entries); - ASSERT_TRUE(ok); - ASSERT_TRUE(pkg_entries_has_name(&entries, "App")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "Core")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "Utils")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "UtilsPkg")); - ASSERT_EQ(entries.count, 1); - cbm_pkg_entries_free(&entries); +TEST(incremental_overlay_publish_repeated_update_keeps_active_view_idempotent) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func OverlayRetryOnly() int {\n\treturn 77\n}\n"), + 0); + + for (int i = 0; i < 2; i++) { + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY); + ASSERT(!cbm_pipeline_graph_changed(p)); + cbm_pipeline_free(p); + } + + ASSERT_EQ(pipeline_store_generation_status_count(g_incr_dbpath, project, + CBM_STORE_INDEX_STATUS_RESERVED), + 0); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayRetryOnly")); + ASSERT_EQ(pipeline_store_overlay_file_function_count(g_incr_dbpath, project, "leaf.go", + "OverlayRetryOnly"), + 1); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 1); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* Fail-closed on any `name:` that is not a bare literal: a computed - * variable, and a literal concatenated with a dynamic suffix (which a - * naive quote-scan would wrongly accept as "App"). Neither mints an entry, - * though the manifest is still recognized and parsed. */ -TEST(pkgmap_swift_ambiguous_target_name_fails_closed) { - static const char dynamic_src[] = - "let generatedName = \"App\" + String(buildNumber)\n" - "let package = Package(\n" - " name: \"App\",\n" - " targets: [.target(name: generatedName, dependencies: [])]\n" - ")\n"; - static const char concat_src[] = - "let package = Package(\n" - " name: \"App\",\n" - " targets: [.target(name: \"App\" + suffix, dependencies: [])]\n" - ")\n"; +TEST(incremental_overlay_publish_failure_falls_back_to_canonical_exact) { + pipeline_env_snapshot_t fail_env = pipeline_env_save(CBM_TEST_FAIL_INCREMENTAL_PHASE); - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", dynamic_src, - (int)strlen(dynamic_src), &entries)); - ASSERT_EQ(entries.count, 0); - cbm_pkg_entries_free(&entries); + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - cbm_pkg_entries_init(&entries); - ASSERT_TRUE(cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", concat_src, - (int)strlen(concat_src), &entries)); - ASSERT_EQ(entries.count, 0); - cbm_pkg_entries_free(&entries); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayFailureOnly")); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func OverlayFailureOnly() int {\n\treturn 88\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + cbm_setenv(CBM_TEST_FAIL_INCREMENTAL_PHASE, CBM_TEST_FAIL_INCREMENTAL_OVERLAY_PUBLISH, 1); + int run_rc = cbm_pipeline_run(p); + pipeline_env_restore(&fail_env); + ASSERT_EQ(run_rc, 0); + ASSERT_EQ(cbm_pipeline_publish_kind(p), CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT); + ASSERT(cbm_pipeline_graph_changed(p)); + cbm_pipeline_free(p); + + ASSERT_EQ(pipeline_store_overlay_generation_status_count( + g_incr_dbpath, project, CBM_STORE_OVERLAY_STATUS_FAILED), + 1); + ASSERT_EQ(pipeline_store_overlay_generation_status_count( + g_incr_dbpath, project, CBM_STORE_OVERLAY_STATUS_READY), + 0); + ASSERT_EQ(pipeline_store_generation_status_count(g_incr_dbpath, project, + CBM_STORE_INDEX_STATUS_RESERVED), + 0); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayFailureOnly")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); + ASSERT_EQ(dirty_overlay_ready, 0); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* The repo-wide manifest walker must also recognize "Package.swift" -- a - * second code path (is_pkgmap_manifest_basename) from the direct - * cbm_pkgmap_try_parse calls above. */ -TEST(pkgmap_swift_scan_repo_finds_nested_manifest) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_swift_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); - char dir[512]; - snprintf(dir, sizeof(dir), "%s/Core", tmpdir); - cbm_mkdir(dir); - write_temp_file(tmpdir, "Core/Package.swift", - "let package = Package(\n" - " name: \"Core\",\n" - " targets: [.target(name: \"Core\", dependencies: [])]\n" - ")\n"); +TEST(incremental_overlay_extract_failure_keeps_dirty_pending_without_overlay) { + pipeline_env_snapshot_t fail_env = pipeline_env_save(CBM_TEST_FAIL_INCREMENTAL_PHASE); - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - cbm_pkgmap_scan_repo(tmpdir, &entries, NULL, 0); - ASSERT_TRUE(pkg_entries_has_name(&entries, "Core")); - cbm_pkg_entries_free(&entries); + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - th_rmtree(tmpdir); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_OVERLAY_PUBLISH, + CBM_CONFIG_OVERLAY_PUBLISH_SMALL_DELTAS), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "OverlayExtractFailureOnly")); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/leaf.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func Leaf() int {\n\treturn 2\n}\n\n" + "func OverlayExtractFailureOnly() int {\n\treturn 99\n}\n"), + 0); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + cbm_file_info_t *files = NULL; + int file_count = 0; + cbm_discover_opts_t opts = {.mode = CBM_MODE_FAST, .ignore_file = NULL, .max_file_size = 0}; + ASSERT_EQ(cbm_discover(g_incr_tmpdir, &opts, &files, &file_count), 0); + ASSERT_GT(file_count, 0); + + cbm_setenv(CBM_TEST_FAIL_INCREMENTAL_PHASE, CBM_TEST_FAIL_INCREMENTAL_EXTRACT, 1); + int run_rc = cbm_pipeline_run_incremental(p, g_incr_dbpath, files, file_count); + pipeline_env_restore(&fail_env); + cbm_discover_free(files, file_count); + ASSERT_NEQ(run_rc, 0); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, + "OverlayExtractFailureOnly")); + ASSERT_EQ(pipeline_store_overlay_generation_status_count( + g_incr_dbpath, project, CBM_STORE_OVERLAY_STATUS_FAILED), + 0); + ASSERT_EQ(pipeline_store_overlay_generation_status_count( + g_incr_dbpath, project, CBM_STORE_OVERLAY_STATUS_READY), + 0); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 1); + ASSERT_EQ(dirty_overlay_ready, 0); + cbm_pipeline_free(p); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* The pkgmap repo walk must honor discovery exclusions (issue #792: a - * gitignored huge subtree kept the pkgmap walk busy for 15 minutes). - * Control run first (no exclusions → BOTH manifests parsed) so the - * exclusion assertion below cannot pass vacuously. */ -TEST(pkgmap_scan_repo_honors_discovery_exclusions) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pkgmap_excl_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(incremental_full_mode_keeps_exact_upsert_disabled) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - char dir[512]; - snprintf(dir, sizeof(dir), "%s/packages", tmpdir); - cbm_mkdir(dir); - snprintf(dir, sizeof(dir), "%s/packages/app", tmpdir); - cbm_mkdir(dir); - snprintf(dir, sizeof(dir), "%s/vendor_big", tmpdir); - cbm_mkdir(dir); - snprintf(dir, sizeof(dir), "%s/vendor_big/lib", tmpdir); - cbm_mkdir(dir); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - write_temp_file(tmpdir, "packages/app/package.json", - "{\"name\":\"@org/app\",\"main\":\"index.js\"}\n"); - write_temp_file(tmpdir, "vendor_big/lib/package.json", - "{\"name\":\"@org/vendored\",\"main\":\"index.js\"}\n"); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + ASSERT_EQ(th_write_file(path, + "package main\n\n" + "func main() {\n\tHelper()\n}\n\n" + "func FullModeNewMain() int {\n\treturn 9\n}\n"), + 0); - /* Control: NULL exclusion list — the walk reaches and parses BOTH - * manifests (proves the excluded one is reachable + parseable). */ - cbm_pkg_entries_t control; - cbm_pkg_entries_init(&control); - cbm_pkgmap_scan_repo(tmpdir, &control, NULL, 0); - ASSERT_TRUE(pkg_entries_has_name(&control, "@org/app")); - ASSERT_TRUE(pkg_entries_has_name(&control, "@org/vendored")); - cbm_pkg_entries_free(&control); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); - /* With vendor_big excluded (as discovery reports for a gitignored - * subtree): the walk must not descend into it. */ - char *excluded[] = {(char *)"vendor_big"}; - cbm_pkg_entries_t entries; - cbm_pkg_entries_init(&entries); - cbm_pkgmap_scan_repo(tmpdir, &entries, excluded, 1); - ASSERT_TRUE(pkg_entries_has_name(&entries, "@org/app")); - ASSERT_FALSE(pkg_entries_has_name(&entries, "@org/vendored")); - cbm_pkg_entries_free(&entries); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "FullModeNewMain")); + int64_t generation = -1; + ASSERT_EQ(pipeline_store_file_state_generation(g_incr_dbpath, project, "main.go", + &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, CBM_PIPELINE_COMPAT_GENERATION); + + char canonical_graph_diff_error[CBM_SZ_8K] = {0}; + int canonical_graph_diff_rc = pipeline_compare_current_db_to_fresh_rebuild( + g_incr_tmpdir, g_incr_dbpath, project, CBM_MODE_FULL, cfg, canonical_graph_diff_error, + sizeof(canonical_graph_diff_error)); + if (canonical_graph_diff_rc != 0) { + printf(" [full-mode:canonical-diff] %s\n", canonical_graph_diff_error); + } + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + ASSERT_EQ(canonical_graph_diff_rc, 0); + PASS(); +} + +TEST(incremental_detects_same_size_rewrite_with_preserved_mtime) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + const char original[] = "package main\n\nfunc Helper() string {\n\treturn \"hello\"\n}\n"; + const char rewritten[] = "package main\n\nfunc Helped() string {\n\treturn \"hello\"\n}\n"; + ASSERT_EQ((int)strlen(original), (int)strlen(rewritten)); + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "Helped")); + + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(path)); + struct stat before; + ASSERT_EQ(stat(path, &before), 0); + ASSERT_EQ((int64_t)before.st_size, (int64_t)strlen(original)); + ASSERT_EQ(th_write_file(path, rewritten), 0); + ASSERT_EQ(pipeline_restore_file_times(path, &before), 0); + + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); - th_rmtree(tmpdir); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Helped")); + + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* The env-URL walk must honor discovery exclusions the same way (#792). - * Control run first via the NULL-exclusion wrapper so the exclusion - * assertion cannot pass vacuously. */ -TEST(envscan_walk_honors_discovery_exclusions) { - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_envscan_excl_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) - FAIL("tmpdir"); +TEST(incremental_missing_file_state_keeps_legacy_metadata_path) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - char dir[512]; - snprintf(dir, sizeof(dir), "%s/big_generated", tmpdir); - cbm_mkdir(dir); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - write_temp_file(tmpdir, "deploy.sh", - "#!/bin/bash\nexport CONTROL_URL=\"https://api.example.com/v1\"\n"); - write_temp_file(tmpdir, "big_generated/env.sh", - "#!/bin/bash\nexport EXCLUDED_URL=\"https://excluded.example.com/v1\"\n"); + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_before = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_before, 0); + ASSERT_EQ(cbm_store_delete_file_state(s, project, "helper.go"), CBM_STORE_OK); + cbm_file_state_t state = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, project, "helper.go", &state), CBM_STORE_NOT_FOUND); + cbm_store_close(s); - /* Control: the NULL-exclusion wrapper sees both bindings. */ - cbm_env_binding_t bindings[32]; - int count = cbm_scan_project_env_urls(tmpdir, bindings, 32); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL")); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "EXCLUDED_URL")); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); - /* With big_generated excluded, its binding must disappear. */ - char *excluded[] = {(char *)"big_generated"}; - count = cbm_scan_project_env_urls_excluded(tmpdir, bindings, 32, excluded, 1); - ASSERT_NOT_NULL(find_binding_by_key(bindings, count, "CONTROL_URL")); - ASSERT_TRUE(find_binding_by_key(bindings, count, "EXCLUDED_URL") == NULL); + s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_count_nodes(s, project), nodes_before); + cbm_store_close(s); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "Helper")); - th_rmtree(tmpdir); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ── Git history tests (port of githistory_test.go) ────────────── */ +TEST(incremental_publish_failure_keeps_existing_db) { + pipeline_env_snapshot_t flush_fail_env = + pipeline_env_save(CBM_TEST_FAIL_GBUF_FLUSH_BEFORE_COMMIT); + pipeline_env_snapshot_t dump_fail_env = + pipeline_env_save(CBM_TEST_FAIL_GBUF_DUMP_BEFORE_REPLACE); -/* Port of Go TestIsTrackableFile from githistory_test.go */ -TEST(githistory_is_trackable_file) { - /* Source files — trackable */ - ASSERT_TRUE(cbm_is_trackable_file("main.go")); - ASSERT_TRUE(cbm_is_trackable_file("src/app.py")); - ASSERT_TRUE(cbm_is_trackable_file("README.md")); + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } - /* node_modules — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file("node_modules/foo/bar.js")); - /* vendor — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file("vendor/lib/dep.go")); - /* Lock files — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file("package-lock.json")); - ASSERT_FALSE(cbm_is_trackable_file("go.sum")); - /* Binary/assets — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file("image.png")); - /* .git directory — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file(".git/config")); - /* __pycache__ — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file("__pycache__/mod.pyc")); - /* Minified files — not trackable */ - ASSERT_FALSE(cbm_is_trackable_file("src/style.min.css")); - PASS(); -} + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); -/* Port of Go TestComputeChangeCoupling from githistory_test.go */ -TEST(githistory_compute_change_coupling) { - /* 5 commits: - * aaa: a.go, b.go, c.go - * bbb: a.go, b.go - * ccc: a.go, b.go - * ddd: a.go, c.go - * eee: d.go, e.go - */ - char *files_aaa[] = {"a.go", "b.go", "c.go"}; - char *files_bbb[] = {"a.go", "b.go"}; - char *files_ccc[] = {"a.go", "b.go"}; - char *files_ddd[] = {"a.go", "c.go"}; - char *files_eee[] = {"d.go", "e.go"}; + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_before = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_before, 0); + cbm_store_close(s); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); - cbm_commit_files_t commits[5] = { - {files_aaa, 3, 0}, {files_bbb, 2, 0}, {files_ccc, 2, 0}, - {files_ddd, 2, 0}, {files_eee, 2, 0}, - }; + char path[CBM_PATH_MAX]; + snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\n" + "func Helper() string {\n\treturn \"hello\"\n}\n\n" + "func NewFunc() int {\n\treturn 42\n}\n"); + fclose(f); - cbm_change_coupling_t out[100]; - int count = cbm_compute_change_coupling(commits, 5, out, 100); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); - /* a.go + b.go co-change 3 times → should be in results */ - bool found_ab = false; - for (int i = 0; i < count; i++) { - if ((strcmp(out[i].file_a, "a.go") == 0 && strcmp(out[i].file_b, "b.go") == 0) || - (strcmp(out[i].file_a, "b.go") == 0 && strcmp(out[i].file_b, "a.go") == 0)) { - found_ab = true; - ASSERT_EQ(out[i].co_change_count, 3); - ASSERT(out[i].coupling_score >= 0.9); - } - } - ASSERT_TRUE(found_ab); + cbm_setenv(CBM_TEST_FAIL_GBUF_FLUSH_BEFORE_COMMIT, pipeline_test_env_enabled, 1); + cbm_setenv(CBM_TEST_FAIL_GBUF_DUMP_BEFORE_REPLACE, pipeline_test_env_enabled, 1); + int rc = cbm_pipeline_run(p); + pipeline_env_restore(&flush_fail_env); + pipeline_env_restore(&dump_fail_env); - /* d.go + e.go co-change only 1 time → below threshold of 3 */ - for (int i = 0; i < count; i++) { - if (strcmp(out[i].file_a, "d.go") == 0 || strcmp(out[i].file_b, "d.go") == 0) { - ASSERT(0); /* d.go should not appear */ - } - } + ASSERT_NEQ(rc, 0); + s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_count_nodes(s, project), nodes_before); + cbm_store_close(s); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); + int dirty_pending = -1; + int dirty_overlay_ready = -1; + ASSERT_EQ(pipeline_store_dirty_counts(g_incr_dbpath, project, &dirty_pending, + &dirty_overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 1); + ASSERT_EQ(dirty_overlay_ready, 0); + s = cbm_store_open_path_query(g_incr_dbpath); + ASSERT_NOT_NULL(s); + cbm_dirty_file_state_t *dirty_rows = NULL; + int dirty_row_count = 0; + ASSERT_EQ(cbm_store_list_dirty_files(s, project, &dirty_rows, &dirty_row_count), + CBM_STORE_OK); + ASSERT_EQ(dirty_row_count, 1); + ASSERT_STR_EQ(dirty_rows[0].project, project); + ASSERT_STR_EQ(dirty_rows[0].rel_path, "helper.go"); + char expected_dirty_hash[CBM_FILE_CONTENT_HASH_BUFSZ] = ""; + ASSERT_EQ(cbm_file_content_hash(path, expected_dirty_hash, sizeof(expected_dirty_hash)), 0); + ASSERT_STR_EQ(dirty_rows[0].observed_hash, expected_dirty_hash); + ASSERT_GT(dirty_rows[0].observed_mtime_ns, 0); + ASSERT_GT(dirty_rows[0].observed_size, 0); + ASSERT_STR_EQ(dirty_rows[0].source, CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX); + ASSERT_STR_EQ(dirty_rows[0].status, CBM_STORE_DIRTY_STATUS_PENDING); + cbm_store_free_dirty_files(dirty_rows, dirty_row_count); + cbm_store_close(s); + + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* Port of Go TestComputeChangeCouplingSkipsLargeCommits from githistory_test.go */ -TEST(githistory_coupling_skips_large_commits) { - /* 25 files in one commit → exceeds 20-file threshold */ - char *files[25]; - char bufs[25][32]; - for (int i = 0; i < 25; i++) { - snprintf(bufs[i], sizeof(bufs[i]), "file%d.go", i); - files[i] = bufs[i]; +TEST(incremental_frontier_full_fallback_failure_preserves_dirty_ledger) { + pipeline_env_snapshot_t flush_fail_env = + pipeline_env_save(CBM_TEST_FAIL_GBUF_FLUSH_BEFORE_COMMIT); + pipeline_env_snapshot_t dump_fail_env = + pipeline_env_save(CBM_TEST_FAIL_GBUF_DUMP_BEFORE_REPLACE); + + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - cbm_commit_files_t commits[1] = {{files, 25, 0}}; + ASSERT_EQ(write_incremental_c_header_frontier_fixture(CBM_ALLOC_ONE), 0); - cbm_change_coupling_t out[100]; - int count = cbm_compute_change_coupling(commits, 1, out, 100); - ASSERT_EQ(count, 0); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + char conservative_cap[CBM_SZ_32]; + int n = snprintf(conservative_cap, sizeof(conservative_cap), "%d", CBM_SZ_4); + ASSERT(n >= 0 && (size_t)n < sizeof(conservative_cap)); + ASSERT_EQ( + cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, conservative_cap), 0); + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = cbm_strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + int nodes_before = cbm_store_count_nodes(store, project); + ASSERT_GT(nodes_before, 0); + cbm_store_close(store); + ASSERT_FALSE(pipeline_store_has_function_name(g_incr_dbpath, project, "shared_extra")); + + ASSERT_EQ(write_incremental_c_header_extra_export(CBM_SZ_16), 0); + char changed_path[CBM_PATH_MAX]; + n = snprintf(changed_path, sizeof(changed_path), "%s/shared.h", g_incr_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(changed_path)); + + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + cbm_setenv(CBM_TEST_FAIL_GBUF_FLUSH_BEFORE_COMMIT, pipeline_test_env_enabled, 1); + cbm_setenv(CBM_TEST_FAIL_GBUF_DUMP_BEFORE_REPLACE, pipeline_test_env_enabled, 1); + pipeline_capture_logs_start(); + int run_rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + pipeline_env_restore(&flush_fail_env); + pipeline_env_restore(&dump_fail_env); + + ASSERT_NEQ(run_rc, 0); + ASSERT(strstr(logs, "msg=incremental.exact.fallback reason=frontier_too_large") != NULL); + char fallback_log[CBM_SZ_128]; + n = snprintf(fallback_log, sizeof(fallback_log), "msg=incremental.fallback reason=%s scope=%s", + CBM_PIPELINE_DELTA_REASON_FRONTIER_TOO_LARGE, + CBM_PIPELINE_DELTA_SCOPE_C_FAMILY_HEADER); + ASSERT(n >= 0 && (size_t)n < sizeof(fallback_log)); + ASSERT(strstr(logs, fallback_log) != NULL); + + store = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_count_nodes(store, project), nodes_before); + cbm_store_close(store); + ASSERT_FALSE(pipeline_store_has_function_name(g_incr_dbpath, project, "shared_extra")); + + store = cbm_store_open_path_query(g_incr_dbpath); + ASSERT_NOT_NULL(store); + cbm_dirty_file_state_t *dirty_rows = NULL; + int dirty_row_count = 0; + ASSERT_EQ(cbm_store_list_dirty_files(store, project, &dirty_rows, &dirty_row_count), + CBM_STORE_OK); + ASSERT_EQ(dirty_row_count, 1); + ASSERT_STR_EQ(dirty_rows[0].project, project); + ASSERT_STR_EQ(dirty_rows[0].rel_path, "shared.h"); + char expected_dirty_hash[CBM_FILE_CONTENT_HASH_BUFSZ] = ""; + ASSERT_EQ(cbm_file_content_hash(changed_path, expected_dirty_hash, sizeof(expected_dirty_hash)), + 0); + ASSERT_STR_EQ(dirty_rows[0].observed_hash, expected_dirty_hash); + ASSERT_GT(dirty_rows[0].observed_mtime_ns, 0); + ASSERT_GT(dirty_rows[0].observed_size, 0); + ASSERT_STR_EQ(dirty_rows[0].source, CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX); + ASSERT_STR_EQ(dirty_rows[0].status, CBM_STORE_DIRTY_STATUS_PENDING); + cbm_store_free_dirty_files(dirty_rows, dirty_row_count); + cbm_store_close(store); + + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* Port of Go TestComputeChangeCouplingLimitsTo100 from githistory_test.go */ -TEST(githistory_coupling_limits_output) { - /* Generate many small commits to create >100 couplings. - * 50 files, each pair committed 3 times. max_out=100. */ - int idx = 0; - char *pair_files[2450][2]; /* 50*49/2 pairs * 3 repetitions = 3675 commits */ - char pair_bufs[2450][2][32]; - cbm_commit_files_t commits[3675]; - int ci = 0; - for (int i = 0; i < 50 && ci < 3675; i++) { - for (int j = i + 1; j < 50 && ci < 3675; j++) { - for (int k = 0; k < 3 && ci < 3675; k++) { - snprintf(pair_bufs[idx][0], 32, "f%d.go", i); - snprintf(pair_bufs[idx][1], 32, "f%d.go", j); - pair_files[idx][0] = pair_bufs[idx][0]; - pair_files[idx][1] = pair_bufs[idx][1]; - commits[ci].files = pair_files[idx]; - commits[ci].count = 2; - ci++; - idx++; - if (idx >= 2450) - idx = 0; /* reuse buffer space */ - } - } +TEST(incremental_postpass_failure_keeps_existing_db) { + pipeline_env_snapshot_t fail_env = pipeline_env_save(CBM_TEST_FAIL_INCREMENTAL_PHASE); + + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - cbm_change_coupling_t out[200]; - int count = cbm_compute_change_coupling(commits, ci, out, 100); - ASSERT(count <= 100); - PASS(); -} + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); -/* Port of Go TestIsImportReachable from resolver_test.go */ -TEST(registry_is_import_reachable) { - const char *import_vals[] = {"proj.handler", "proj.shared.utils"}; + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_before = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_before, 0); + cbm_store_close(s); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); - /* Exact match: proj.handler.Process → true */ - ASSERT_TRUE(cbm_registry_is_import_reachable("proj.handler.Process", import_vals, 2)); - /* Sub-package: proj.handler.sub.Process → true (handler contains handler) */ - ASSERT_TRUE(cbm_registry_is_import_reachable("proj.handler.sub.Process", import_vals, 2)); - /* Nested match: proj.shared.utils.Helper → true */ - ASSERT_TRUE(cbm_registry_is_import_reachable("proj.shared.utils.Helper", import_vals, 2)); - /* Unrelated: proj.billing.Process → false */ - ASSERT_FALSE(cbm_registry_is_import_reachable("proj.billing.Process", import_vals, 2)); - /* Completely unrelated: unrelated.pkg.Func → false */ - ASSERT_FALSE(cbm_registry_is_import_reachable("unrelated.pkg.Func", import_vals, 2)); - PASS(); -} + char path[CBM_PATH_MAX]; + snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\n" + "func Helper() string {\n\treturn \"hello\"\n}\n\n" + "func NewFunc() int {\n\treturn 42\n}\n"); + fclose(f); -/* Port of FindEndingWith portion from Go TestFunctionRegistry in pipeline_test.go */ -TEST(registry_find_ending_with) { - cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); - cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); - cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); - cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method"); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); - /* FindEndingWith "DataProcessor.transform" → 1 match */ - const char **matches = NULL; - int count = cbm_registry_find_ending_with(reg, "DataProcessor.transform", &matches); - ASSERT_EQ(count, 1); - ASSERT_STR_EQ(matches[0], "proj.utils.DataProcessor.transform"); - free(matches); + cbm_file_info_t *files = NULL; + int file_count = 0; + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL, .ignore_file = NULL, .max_file_size = 0}; + ASSERT_EQ(cbm_discover(g_incr_tmpdir, &opts, &files, &file_count), 0); + ASSERT_GT(file_count, 0); - /* FindEndingWith "Foo" → 2 matches */ - matches = NULL; - count = cbm_registry_find_ending_with(reg, "Foo", &matches); - ASSERT_EQ(count, 2); - /* Both should be present (order may vary) */ - bool found_pkg = false, found_other = false; - for (int i = 0; i < count; i++) { - if (strcmp(matches[i], "proj.pkg.Foo") == 0) - found_pkg = true; - if (strcmp(matches[i], "proj.other.Foo") == 0) - found_other = true; - } - ASSERT_TRUE(found_pkg); - ASSERT_TRUE(found_other); - free(matches); + cbm_setenv(CBM_TEST_FAIL_INCREMENTAL_PHASE, CBM_TEST_FAIL_INCREMENTAL_POSTPASS, 1); + int rc = cbm_pipeline_run_incremental(p, g_incr_dbpath, files, file_count); + pipeline_env_restore(&fail_env); + cbm_discover_free(files, file_count); - /* FindEndingWith "Nonexistent" → 0 matches */ - matches = NULL; - count = cbm_registry_find_ending_with(reg, "Nonexistent", &matches); - ASSERT_EQ(count, 0); + ASSERT_NEQ(rc, 0); + s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_count_nodes(s, project), nodes_before); + cbm_store_close(s); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); - cbm_registry_free(reg); + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); PASS(); } -/* ═══════════════════════════════════════════════════════════════════ - * Incremental reindex tests - * ═══════════════════════════════════════════════════════════════════ */ - -/* Helper: create a simple 2-file Go project for incremental tests */ -static char g_incr_tmpdir[256]; -static char g_incr_dbpath[512]; +TEST(incremental_hash_persist_failure_falls_back_to_full) { + pipeline_env_snapshot_t fail_env = pipeline_env_save(CBM_TEST_FAIL_INCREMENTAL_PHASE); -static int setup_incremental_repo(void) { - snprintf(g_incr_tmpdir, sizeof(g_incr_tmpdir), "/tmp/cbm_incr_XXXXXX"); - if (!cbm_mkdtemp(g_incr_tmpdir)) { - return -1; + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - snprintf(g_incr_dbpath, sizeof(g_incr_dbpath), "%s/test.db", g_incr_tmpdir); - - char path[512]; - FILE *f; - /* main.go — calls Helper() */ - snprintf(path, sizeof(path), "%s/main.go", g_incr_tmpdir); - f = fopen(path, "w"); - if (!f) { - return -1; - } - fprintf(f, "package main\n\nfunc main() {\n\tHelper()\n}\n"); - fclose(f); + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_GTE(pipeline_store_file_hash_count(g_incr_dbpath, project), 2); + ASSERT(!pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); - /* helper.go — defines Helper() */ + char path[CBM_PATH_MAX]; snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); - f = fopen(path, "w"); - if (!f) { - return -1; - } - fprintf(f, "package main\n\nfunc Helper() string {\n\treturn \"hello\"\n}\n"); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\n" + "func Helper() string {\n\treturn \"hello\"\n}\n\n" + "func NewFunc() int {\n\treturn 42\n}\n"); fclose(f); - return 0; + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + + cbm_setenv(CBM_TEST_FAIL_INCREMENTAL_PHASE, CBM_TEST_FAIL_INCREMENTAL_HASH_PERSIST, 1); + int rc = cbm_pipeline_run(p); + pipeline_env_restore(&fail_env); + + ASSERT_EQ(rc, 0); + ASSERT(pipeline_store_has_function_name(g_incr_dbpath, project, "NewFunc")); + ASSERT_GTE(pipeline_store_file_hash_count(g_incr_dbpath, project), 2); + + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); } -static void cleanup_incremental_repo(void) { - th_rmtree(g_incr_tmpdir); +TEST(incremental_parallel_extract_failure_keeps_existing_db) { + ASSERT_EQ(run_parallel_incremental_phase_failure_case(CBM_TEST_FAIL_INCREMENTAL_EXTRACT), 0); + PASS(); } -/* Atomic-publish cancellation seam. Production invokes this hook after the - * staging database is complete, closed, and integrity-valid, but immediately - * before it can replace the last committed database. Keeping the hook on the - * pipeline instance avoids process-global failpoints and makes cancellation - * tests deterministic even when test runners gain concurrency. */ -extern void cbm_pipeline_set_before_publish_hook_for_tests( - cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx); +TEST(incremental_parallel_success_releases_package_map) { + ASSERT_EQ(setup_incremental_parallel_repo(), 0); -typedef struct { - const char *project; - const char *candidate; - char staging_path[768]; - int calls; - bool staging_existed; - bool staging_was_valid; - int staged_candidates; -} publish_cancel_ctx_t; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); -typedef struct { - char staging_path[CBM_SZ_4K]; - int calls; - bool staging_was_valid; -} publish_observe_ctx_t; + ASSERT_EQ(rewrite_incremental_parallel_repo(), 0); + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + ASSERT_NULL(cbm_pipeline_get_pkgmap()); -static void observe_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) { - (void)p; - publish_observe_ctx_t *ctx = (publish_observe_ctx_t *)arg; - ctx->calls++; - if (!staging_path) { - return; - } - int n = snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path); - if (n < 0 || (size_t)n >= sizeof(ctx->staging_path)) { - ctx->staging_path[0] = '\0'; - return; - } - cbm_store_t *staging = cbm_store_open_path_existing(staging_path); - if (staging) { - ctx->staging_was_valid = cbm_store_check_integrity(staging); - cbm_store_close(staging); - } + cbm_pipeline_free(p); + cbm_config_close(cfg); + cleanup_incremental_repo(); + PASS(); } -typedef struct { - int calls; -} publish_rename_fail_ctx_t; - -static int fail_publish_rename(const char *staging_path, const char *final_path, void *arg) { - publish_rename_fail_ctx_t *ctx = (publish_rename_fail_ctx_t *)arg; - ctx->calls++; - return staging_path && final_path ? CBM_NOT_FOUND : 0; +TEST(incremental_parallel_registry_failure_keeps_existing_db) { + ASSERT_EQ(run_parallel_incremental_phase_failure_case(CBM_TEST_FAIL_INCREMENTAL_REGISTRY), 0); + PASS(); } -static bool pipeline_fixture_file_equals(const char *path, const char *expected) { - FILE *f = cbm_fopen(path, "rb"); - if (!f) { - return false; - } - size_t expected_len = strlen(expected); - char actual[64]; - size_t n = fread(actual, 1, sizeof(actual), f); - bool ok = n == expected_len && memcmp(actual, expected, expected_len) == 0 && fgetc(f) == EOF; - (void)fclose(f); - return ok; +TEST(incremental_parallel_resolve_failure_keeps_existing_db) { + ASSERT_EQ(run_parallel_incremental_phase_failure_case(CBM_TEST_FAIL_INCREMENTAL_RESOLVE), 0); + PASS(); } -static void cancel_at_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) { - publish_cancel_ctx_t *ctx = (publish_cancel_ctx_t *)arg; - ctx->calls++; - if (staging_path && staging_path[0]) { - snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path); - struct stat st; - ctx->staging_existed = stat(staging_path, &st) == 0; - if (ctx->staging_existed) { - cbm_store_t *staging = cbm_store_open_path(staging_path); - if (staging) { - ctx->staging_was_valid = cbm_store_check_integrity(staging); - ctx->staged_candidates = count_nodes_named(staging, ctx->project, ctx->candidate); - cbm_store_close(staging); - } - } - } - cbm_pipeline_cancel(p); +TEST(incremental_classify_deleted_failure_keeps_existing_db) { + ASSERT_EQ(run_parallel_incremental_phase_failure_case(CBM_TEST_FAIL_INCREMENTAL_CLASSIFY_DELETED), + 0); + PASS(); } -static bool sqlite_artifacts_absent(const char *db_path) { - struct stat st; - if (!db_path || !db_path[0] || stat(db_path, &st) == 0) { - return false; - } - char sidecar[832]; - snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path); - if (stat(sidecar, &st) == 0) { - return false; - } - snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path); - if (stat(sidecar, &st) == 0) { - return false; +TEST(incremental_detects_deleted_file) { + /* Full index, delete a file, re-index → deleted file's nodes removed */ + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); } - snprintf(sidecar, sizeof(sidecar), "%s-journal", db_path); - return stat(sidecar, &st) != 0; -} -static bool write_go_file(const char *dir, const char *name, const char *source) { + /* First: full index */ + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + + /* Delete helper.go */ char path[512]; - snprintf(path, sizeof(path), "%s/%s", dir, name); - FILE *f = fopen(path, "w"); - if (!f) { - return false; - } - bool ok = fputs(source, f) >= 0; - fclose(f); - return ok; -} + snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); + unlink(path); -/* ═══════════════════════════════════════════════════════════════════ - * FastAPI Depends() edge tracking (PR #66, fix #27) - * ═══════════════════════════════════════════════════════════════════ */ + /* Second: incremental — should remove Helper nodes */ + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); -TEST(pipeline_fastapi_depends_edges) { - /* Depends(get_current_user) should produce a CALLS edge from the - * endpoint to the dependency function. */ - const char *files[] = {"auth.py", "routes.py"}; - const char *contents[] = {/* auth.py: defines get_current_user */ - "def get_current_user(token: str):\n" - " return decode_token(token)\n", - /* routes.py: endpoint depends on get_current_user */ - "from fastapi import Depends\n" - "from auth import get_current_user\n\n" - "def get_profile(user = Depends(get_current_user)):\n" - " return {\"user\": user}\n"}; - if (setup_lang_repo(files, contents, 2) != 0) { - FAIL("tmpdir"); - } - char db[512]; - snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir); - cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL); + /* Verify node count decreased (Helper's file was deleted) */ + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_after = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_after, 0); /* still has main.go nodes */ + cbm_store_close(s); + cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + + cleanup_incremental_repo(); + PASS(); +} + +TEST(incremental_new_file_added) { + /* Full index, add a new file, re-index → new file's nodes appear */ + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + /* First: full index */ + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); - cbm_store_t *s = cbm_store_open_path(db); - ASSERT_NOT_NULL(s); - const char *proj = cbm_pipeline_project_name(p); - - /* Check CALLS edges for fastapi_depends strategy */ - cbm_edge_t *edges = NULL; - int edge_count = 0; - cbm_store_find_edges_by_type(s, proj, "CALLS", &edges, &edge_count); + /* Add extra.go */ + char path[512]; + snprintf(path, sizeof(path), "%s/extra.go", g_incr_tmpdir); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\nfunc Extra() bool {\n\treturn true\n}\n"); + fclose(f); - bool found_depends_edge = false; - for (int i = 0; i < edge_count; i++) { - if (edges[i].properties_json && strstr(edges[i].properties_json, "fastapi_depends")) { - found_depends_edge = true; - break; - } - } - if (edges) { - cbm_store_free_edges(edges, edge_count); - } - ASSERT_TRUE(found_depends_edge); + /* Second: incremental — should pick up Extra */ + cbm_config_t *cfg = incremental_test_config(g_incr_tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(s); + int nodes_after = cbm_store_count_nodes(s, project); + ASSERT_GT(nodes_after, 0); cbm_store_close(s); cbm_pipeline_free(p); - teardown_lang_repo(); + free(project); + cbm_config_close(cfg); + + cleanup_incremental_repo(); PASS(); } -/* DLL resolve test removed — feature removed due to Windows Defender - * false positive (Wacatac.B!ml). See issue #89. */ +/* Cancellation at the final publish boundary must never destroy the last good + * full index. Adding two files to the two-file baseline deliberately exceeds + * the incremental router's 1.5x file-count bound (4 > 2 + 2/2), forcing the + * full-reindex path while an existing committed DB is present. */ +TEST(cancelled_full_reindex_preserves_committed_db) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } -/* ═══════════════════════════════════════════════════════════════════ - * Incremental reindex - * ═══════════════════════════════════════════════════════════════════ */ + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); -TEST(incremental_full_then_noop) { - /* Full index, then re-run → should detect no changes and skip */ + cbm_store_t *live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + ASSERT_TRUE(cbm_store_check_integrity(live)); + int baseline_nodes = cbm_store_count_nodes(live, project); + int baseline_helper = count_nodes_named(live, project, "Helper"); + ASSERT_GT(baseline_nodes, 0); + ASSERT_GT(baseline_helper, 0); + ASSERT_EQ(count_nodes_named(live, project, "CandidateFull"), 0); + cbm_store_close(live); + + ASSERT_TRUE(write_go_file(g_incr_tmpdir, "candidate_full.go", + "package main\n\nfunc CandidateFull() int { return 41 }\n")); + ASSERT_TRUE(write_go_file(g_incr_tmpdir, "force_full.go", + "package main\n\nfunc ForceFullRoute() int { return 42 }\n")); + + publish_cancel_ctx_t hook = { + .project = project, + .candidate = "CandidateFull", + }; + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + bool live_valid = cbm_store_check_integrity(live); + int nodes_after = cbm_store_count_nodes(live, project); + int helper_after = count_nodes_named(live, project, "Helper"); + int published_candidates = count_nodes_named(live, project, "CandidateFull"); + cbm_store_close(live); + bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path); + + free(project); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(hook.staging_existed); + ASSERT_TRUE(hook.staging_was_valid); + ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: new full output was staged */ + ASSERT_EQ(rc, -1); + ASSERT_TRUE(live_valid); + ASSERT_EQ(nodes_after, baseline_nodes); + ASSERT_EQ(helper_after, baseline_helper); + ASSERT_EQ(published_candidates, 0); + ASSERT_TRUE(staging_cleaned); + PASS(); +} + +/* The same contract applies to the incremental path: the modified file is + * present in a complete staging DB at the hook, but cancellation leaves the + * prior committed snapshot queryable and removes every staging artifact. */ +TEST(cancelled_incremental_reindex_preserves_committed_db) { if (setup_incremental_repo() != 0) { FAIL("setup failed"); } - /* First: full index */ cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); ASSERT_EQ(cbm_pipeline_run(p), 0); char *project = strdup(cbm_pipeline_project_name(p)); cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); - /* Verify nodes exist */ - cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(s); - int nodes_before = cbm_store_count_nodes(s, project); - ASSERT_GT(nodes_before, 0); - cbm_store_close(s); + cbm_store_t *live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + ASSERT_TRUE(cbm_store_check_integrity(live)); + int baseline_nodes = cbm_store_count_nodes(live, project); + int baseline_helper = count_nodes_named(live, project, "Helper"); + ASSERT_GT(baseline_nodes, 0); + ASSERT_GT(baseline_helper, 0); + ASSERT_EQ(count_nodes_named(live, project, "CandidateIncremental"), 0); + cbm_store_close(live); - /* Second: incremental — nothing changed → should be no-op */ + ASSERT_TRUE(write_go_file(g_incr_tmpdir, "helper.go", + "package main\n\n" + "func Helper() string { return \"hello\" }\n\n" + "func CandidateIncremental() int { return 43 }\n")); + + publish_cancel_ctx_t hook = { + .project = project, + .candidate = "CandidateIncremental", + }; p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + bool live_valid = cbm_store_check_integrity(live); + int nodes_after = cbm_store_count_nodes(live, project); + int helper_after = count_nodes_named(live, project, "Helper"); + int published_candidates = count_nodes_named(live, project, "CandidateIncremental"); + cbm_store_close(live); + bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path); + + free(project); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(hook.staging_existed); + ASSERT_TRUE(hook.staging_was_valid); + ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: changed output was staged */ + ASSERT_EQ(rc, -1); + ASSERT_TRUE(live_valid); + ASSERT_EQ(nodes_after, baseline_nodes); + ASSERT_EQ(helper_after, baseline_helper); + ASSERT_EQ(published_candidates, 0); + ASSERT_TRUE(staging_cleaned); + PASS(); +} + +TEST(backup_failed_publish_failure_preserves_final_sidecars) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char final_path[512]; + char wal_path[544]; + char shm_path[544]; + char journal_path[544]; + snprintf(final_path, sizeof(final_path), "%s/blocked.db", g_incr_tmpdir); + snprintf(wal_path, sizeof(wal_path), "%s-wal", final_path); + snprintf(shm_path, sizeof(shm_path), "%s-shm", final_path); + snprintf(journal_path, sizeof(journal_path), "%s-journal", final_path); + + /* Invalid SQLite makes backup fail. The live sidecars may contain the only + * recoverable state, so the rebuilt generation must be refused without + * changing any member of the old database family. */ + ASSERT_EQ(th_write_file(final_path, "corrupt-main"), 0); + ASSERT_EQ(th_write_file(wal_path, "live-wal"), 0); + ASSERT_EQ(th_write_file(shm_path, "live-shm"), 0); + ASSERT_EQ(th_write_file(journal_path, "live-journal"), 0); + + publish_observe_ctx_t hook = {0}; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook); + pipeline_capture_logs_start(); + int rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + cbm_pipeline_free(p); + + bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main"); + bool wal_preserved = pipeline_fixture_file_equals(wal_path, "live-wal"); + bool shm_preserved = pipeline_fixture_file_equals(shm_path, "live-shm"); + bool journal_preserved = pipeline_fixture_file_equals(journal_path, "live-journal"); + + (void)cbm_unlink(final_path); + (void)cbm_unlink(wal_path); + (void)cbm_unlink(shm_path); + (void)cbm_unlink(journal_path); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(hook.staging_was_valid); + ASSERT_TRUE(rc != 0); + ASSERT_TRUE(final_preserved); + ASSERT_TRUE(wal_preserved); + ASSERT_TRUE(shm_preserved); + ASSERT_TRUE(journal_preserved); + ASSERT_NOT_NULL(strstr(logs, "reason=backup_failed_sidecars_preserved")); + ASSERT_NOT_NULL(strstr(logs, "reason=destination_prepare")); + PASS(); +} + +TEST(backup_failed_rename_failure_preserves_corrupt_main) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char final_path[512]; + snprintf(final_path, sizeof(final_path), "%s/corrupt.db", g_incr_tmpdir); + ASSERT_EQ(th_write_file(final_path, "corrupt-main-before-rename"), 0); + + publish_observe_ctx_t observe = {0}; + publish_rename_fail_ctx_t rename_fail = {0}; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &observe); + cbm_pipeline_set_rename_hook_for_tests(p, fail_publish_rename, &rename_fail); + pipeline_capture_logs_start(); + int rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); cbm_pipeline_free(p); - s = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(s); - int nodes_after = cbm_store_count_nodes(s, project); - /* Node count should be same (no duplicates, no loss) */ - ASSERT_EQ(nodes_after, nodes_before); - cbm_store_close(s); - free(project); + bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main-before-rename"); + (void)cbm_unlink(final_path); + cleanup_incremental_repo(); + + ASSERT_EQ(observe.calls, 1); + ASSERT_TRUE(observe.staging_was_valid); + ASSERT_EQ(rename_fail.calls, 1); + ASSERT_TRUE(rc != 0); + ASSERT_TRUE(final_preserved); + ASSERT_NOT_NULL(strstr(logs, "reason=rename_replace")); + PASS(); +} + +#ifdef __linux__ +static void cleanup_long_db_fixture(char *deep_dir, const char *root, const char *db_path, + const char *staging_path) { + (void)cbm_unlink(db_path); + (void)cbm_remove_db_sidecars(db_path); + + /* The unfixed full-dump path writes to the first 1023 bytes of the + * staging name. Remove that sibling so the RED test cleans up after + * itself as well as the fixed implementation. */ + if (staging_path && strlen(staging_path) >= CBM_SZ_1K) { + char truncated[CBM_SZ_1K]; + memcpy(truncated, staging_path, sizeof(truncated) - 1); + truncated[sizeof(truncated) - 1] = '\0'; + (void)cbm_unlink(truncated); + (void)cbm_remove_db_sidecars(truncated); + } - cleanup_incremental_repo(); - PASS(); + size_t root_len = strlen(root); + while (strlen(deep_dir) > root_len) { + (void)cbm_rmdir(deep_dir); + char *slash = strrchr(deep_dir, '/'); + if (!slash) { + break; + } + *slash = '\0'; + } + (void)cbm_rmdir(root); } -TEST(incremental_detects_changed_file) { - /* Full index, modify one file, re-index → changed file re-parsed */ +TEST(full_reindex_preserves_exact_long_db_path) { if (setup_incremental_repo() != 0) { FAIL("setup failed"); } + char *created_root = th_mktempdir("cbm_long_db"); + ASSERT_NOT_NULL(created_root); + char root[256]; + snprintf(root, sizeof(root), "%s", created_root); - /* First: full index */ - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + char deep_dir[1600]; + snprintf(deep_dir, sizeof(deep_dir), "%s", root); + static const char component[] = + "/segment_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + while (strlen(deep_dir) < 1100) { + size_t used = strlen(deep_dir); + ASSERT_TRUE(used + sizeof(component) < sizeof(deep_dir)); + memcpy(deep_dir + used, component, sizeof(component)); + } + ASSERT_TRUE(cbm_mkdir_p(deep_dir, 0755)); + + char db_path[1600]; + int db_n = snprintf(db_path, sizeof(db_path), "%s/graph.db", deep_dir); + ASSERT_TRUE(db_n > CBM_SZ_1K && (size_t)db_n < sizeof(db_path)); + + publish_observe_ctx_t hook = {0}; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, db_path, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); char *project = strdup(cbm_pipeline_project_name(p)); + ASSERT_NOT_NULL(project); + cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); cbm_pipeline_free(p); - /* Modify helper.go — add a new function */ - char path[512]; - snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); - FILE *f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "package main\n\n" - "func Helper() string {\n\treturn \"hello\"\n}\n\n" - "func NewFunc() int {\n\treturn 42\n}\n"); - fclose(f); - - /* Second: incremental — should detect change and re-index */ - p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); + int node_count = -1; + cbm_store_t *published = rc == 0 ? cbm_store_open_path_existing(db_path) : NULL; + if (published) { + node_count = cbm_store_count_nodes(published, project); + cbm_store_close(published); + } + bool stray_truncated_db = false; + if (strlen(hook.staging_path) >= CBM_SZ_1K) { + char truncated[CBM_SZ_1K]; + memcpy(truncated, hook.staging_path, sizeof(truncated) - 1); + truncated[sizeof(truncated) - 1] = '\0'; + stray_truncated_db = access(truncated, F_OK) == 0; + } - /* Verify node count increased (NewFunc was added) */ - cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(s); - int nodes_after = cbm_store_count_nodes(s, project); - ASSERT_GT(nodes_after, 0); - cbm_store_close(s); - cbm_pipeline_free(p); free(project); - + cleanup_long_db_fixture(deep_dir, root, db_path, hook.staging_path); cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(strlen(hook.staging_path) >= CBM_SZ_1K); + ASSERT_EQ(rc, 0); + ASSERT_GT(node_count, 0); + ASSERT_FALSE(stray_truncated_db); PASS(); } +#endif -TEST(incremental_aborts_when_previous_coverage_is_unreadable) { - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); +TEST(incremental_fast_preserves_mode_skipped_tools_dir) { + /* Regression: 2026-04-13. A fast-mode reindex after a full-mode index + * was silently destroying every file under FAST_SKIP_DIRS directories + * (`tools`, `scripts`, `bin`, `build`, `docs`, ...) by classifying them + * as deleted in find_deleted_files even though they still existed on + * disk. The Skyline graph lost packages/mcp/src/tools/ (18 files / ~500 + * nodes) mid-session when a concurrent /develop run obediently called + * mode='fast'. This test pins the additive semantics: lesser-mode + * reindexes must NOT delete files that are merely outside the current + * pass's discovery scope. Fix: find_deleted_files now stat()s each + * stored-but-missing file and only purges it if it is truly absent + * from disk. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_modeskip_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("tmpdir"); } + char dbpath[512]; + snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); + cbm_config_t *cfg = incremental_test_config(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - char *project = strdup(cbm_pipeline_project_name(p)); - cbm_pipeline_free(p); + char path[512]; + FILE *f; - cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(s); - int nodes_before = cbm_store_count_nodes(s, project); - ASSERT_GT(nodes_before, 0); - /* Simulate an unreadable prior coverage generation while leaving the - * graph and file hashes healthy enough to otherwise run incrementally. */ - ASSERT_EQ( - cbm_store_exec(s, "ALTER TABLE index_coverage RENAME COLUMN detail TO broken_detail;"), - CBM_STORE_OK); - cbm_store_close(s); + /* main.go — root-level production code (visible in fast and full) */ + snprintf(path, sizeof(path), "%s/main.go", tmpdir); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\nfunc main() {\n}\n"); + fclose(f); - char path[512]; - snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); - FILE *f = fopen(path, "a"); + /* tools/util.go — production code under a FAST_SKIP_DIRS directory. + * Full mode indexes it; fast mode skips it via the discover.c heuristic. */ + char tools_dir[512]; + snprintf(tools_dir, sizeof(tools_dir), "%s/tools", tmpdir); + cbm_mkdir_p(tools_dir, 0755); + snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir); + f = fopen(path, "w"); ASSERT_NOT_NULL(f); - fprintf(f, "\nfunc MustNotBeIndexed() int { return 7 }\n"); + fprintf(f, "package tools\n\nfunc Util() string {\n\treturn \"u\"\n}\n"); fclose(f); - p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + /* Step 1: full-mode index — both files should be present */ + cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - ASSERT_TRUE(cbm_pipeline_run(p) != 0); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); cbm_pipeline_free(p); - /* Failure happens before the dump/replacement boundary, preserving the - * original graph rather than publishing a falsely complete generation. */ - s = cbm_store_open_path(g_incr_dbpath); + cbm_store_t *s = cbm_store_open_path(dbpath); ASSERT_NOT_NULL(s); - ASSERT_EQ(cbm_store_count_nodes(s, project), nodes_before); + cbm_node_t *tools_nodes_before = NULL; + int tools_count_before = 0; + cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_before, + &tools_count_before); + ASSERT_GT(tools_count_before, 0); /* full mode must see tools/util.go */ + cbm_store_free_nodes(tools_nodes_before, tools_count_before); + int total_before = cbm_store_count_nodes(s, project); + char dep_project[CBM_SZ_512]; + int dep_len = snprintf(dep_project, sizeof(dep_project), "%s.dep.requests", project); + ASSERT_TRUE(dep_len > 0 && (size_t)dep_len < sizeof(dep_project)); + ASSERT_EQ(cbm_store_upsert_project(s, dep_project, "/tmp/requests"), CBM_STORE_OK); + cbm_node_t dep_node = { + .project = dep_project, + .label = "Module", + .name = "requests", + .qualified_name = dep_project, + .file_path = "__init__.py", + .start_line = 1, + .end_line = 1, + .properties_json = "{}", + }; + ASSERT_GT(cbm_store_upsert_node(s, &dep_node), 0); + ASSERT_GT(cbm_store_count_nodes(s, dep_project), 0); cbm_store_close(s); - free(project); - - cleanup_incremental_repo(); - PASS(); -} -TEST(incremental_detects_deleted_file) { - /* Full index, delete a file, re-index → deleted file's nodes removed */ - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); - } - - /* First: full index */ - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + /* Step 2: fast-mode reindex — tools/util.go MUST survive (additive semantics) */ + p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); ASSERT_EQ(cbm_pipeline_run(p), 0); - char *project = strdup(cbm_pipeline_project_name(p)); cbm_pipeline_free(p); - /* Delete helper.go */ - char path[512]; - snprintf(path, sizeof(path), "%s/helper.go", g_incr_tmpdir); - unlink(path); - - /* Second: incremental — should remove Helper nodes */ - p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - - /* Verify node count decreased (Helper's file was deleted) */ - cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + s = cbm_store_open_path(dbpath); ASSERT_NOT_NULL(s); - int nodes_after = cbm_store_count_nodes(s, project); - ASSERT_GT(nodes_after, 0); /* still has main.go nodes */ - cbm_store_close(s); - cbm_pipeline_free(p); - free(project); + cbm_node_t *tools_nodes_after = NULL; + int tools_count_after = 0; + cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_after, + &tools_count_after); + /* The critical assertion: tools/util.go nodes must still be present after + * a fast-mode reindex that skipped the tools/ directory. Before the fix, + * this was 0. */ + ASSERT_GT(tools_count_after, 0); + ASSERT_EQ(tools_count_after, tools_count_before); /* same nodes, untouched */ + cbm_store_free_nodes(tools_nodes_after, tools_count_after); - cleanup_incremental_repo(); - PASS(); -} + /* Sanity: total node count should not have collapsed by ~the size of tools/ */ + int total_after = cbm_store_count_nodes(s, project); + ASSERT_GTE(total_after, total_before); /* additive — never less */ + cbm_store_close(s); -TEST(incremental_new_file_added) { - /* Full index, add a new file, re-index → new file's nodes appear */ - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); + /* Step 3: mutate main.go and fast reindex — forces publish_and_persist to + * run (instead of the noop early-return path that step 2 hit). This is + * the real dangerous path: the gbuf gets loaded, mutated for main.go, + * and published back to the store. tools/util.go must survive that cycle, + * not just the trivial noop path. Audit finding from 2026-04-13. */ + snprintf(path, sizeof(path), "%s/main.go", tmpdir); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "package main\n\nfunc main() {\n\tprintln(\"changed\")\n}\n"); + fclose(f); + /* Bump mtime explicitly — some filesystems have coarse mtime resolution + * and the rewrite could land in the same tick as the original write. */ +#ifndef _WIN32 + struct stat mst; + if (stat(path, &mst) == 0) { + struct timespec times[2]; + times[0].tv_sec = mst.st_atime; + times[0].tv_nsec = 0; + times[1].tv_sec = mst.st_mtime + 5; + times[1].tv_nsec = 0; + utimensat(AT_FDCWD, path, times, 0); } +#endif /* !_WIN32 */ - /* First: full index */ - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); ASSERT_EQ(cbm_pipeline_run(p), 0); - char *project = strdup(cbm_pipeline_project_name(p)); cbm_pipeline_free(p); - /* Add extra.go */ - char path[512]; - snprintf(path, sizeof(path), "%s/extra.go", g_incr_tmpdir); - FILE *f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "package main\n\nfunc Extra() bool {\n\treturn true\n}\n"); - fclose(f); + s = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(s); + cbm_node_t *tools_nodes_run3 = NULL; + int tools_count_run3 = 0; + cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_run3, &tools_count_run3); + /* tools/util.go nodes must STILL be present after a fast reindex that + * actually ran the full publish_and_persist cycle (not the noop fast-path). */ + ASSERT_EQ(tools_count_run3, tools_count_before); + cbm_store_free_nodes(tools_nodes_run3, tools_count_run3); + ASSERT_GT(cbm_store_count_nodes(s, dep_project), 0); + cbm_store_close(s); - /* Second: incremental — should pick up Extra */ - p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + char canonical_graph_diff_error[CBM_SZ_8K] = {0}; + int canonical_graph_diff_rc = pipeline_compare_current_db_to_fresh_rebuild( + tmpdir, dbpath, project, CBM_MODE_FULL, cfg, canonical_graph_diff_error, + sizeof(canonical_graph_diff_error)); + if (canonical_graph_diff_rc != 0) { + printf(" [full-to-fast-mode:canonical-diff] %s\n", canonical_graph_diff_error); + } + + /* Step 4: actually delete tools/util.go from disk and full-reindex. + * Now it really is gone, so its nodes should be purged. This pins the + * other half of the contract: the stat-based check correctly identifies + * truly-deleted files as deleted. */ + snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir); + unlink(path); + + p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_pipeline_free(p); - cbm_store_t *s = cbm_store_open_path(g_incr_dbpath); + s = cbm_store_open_path(dbpath); ASSERT_NOT_NULL(s); - int nodes_after = cbm_store_count_nodes(s, project); - ASSERT_GT(nodes_after, 0); + cbm_node_t *tools_nodes_deleted = NULL; + int tools_count_deleted = 0; + cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_deleted, + &tools_count_deleted); + ASSERT_EQ(tools_count_deleted, 0); /* truly deleted → purged */ + cbm_store_free_nodes(tools_nodes_deleted, tools_count_deleted); cbm_store_close(s); - cbm_pipeline_free(p); - free(project); - cleanup_incremental_repo(); + free(project); + cbm_config_close(cfg); + th_rmtree(tmpdir); + ASSERT_EQ(canonical_graph_diff_rc, 0); PASS(); } -/* Cancellation at the final publish boundary must never destroy the last good - * full index. Adding two files to the two-file baseline deliberately exceeds - * the incremental router's 1.5x file-count bound (4 > 2 + 2/2), forcing the - * full-reindex path while an existing committed DB is present. */ -TEST(cancelled_full_reindex_preserves_committed_db) { - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); +TEST(incremental_k8s_manifest_indexed) { + /* Full index with a k8s manifest, then add a new manifest via incremental. + * Verifies that cbm_pipeline_pass_k8s() runs during incremental re-index. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_k8s_incr_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("tmpdir"); } + char dbpath[512]; + snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); + char path[512]; + FILE *f; - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + /* Initial manifest */ + snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n"); + fclose(f); + + /* Full index */ + cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); ASSERT_EQ(cbm_pipeline_run(p), 0); char *project = strdup(cbm_pipeline_project_name(p)); cbm_pipeline_free(p); - ASSERT_NOT_NULL(project); - cbm_store_t *live = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(live); - ASSERT_TRUE(cbm_store_check_integrity(live)); - int baseline_nodes = cbm_store_count_nodes(live, project); - int baseline_helper = count_nodes_named(live, project, "Helper"); - ASSERT_GT(baseline_nodes, 0); - ASSERT_GT(baseline_helper, 0); - ASSERT_EQ(count_nodes_named(live, project, "CandidateFull"), 0); - cbm_store_close(live); + /* Verify Resource node created by full index */ + cbm_store_t *s = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(s); + cbm_node_t *nodes = NULL; + int count = 0; + cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count); + ASSERT_GT(count, 0); + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); - ASSERT_TRUE(write_go_file(g_incr_tmpdir, "candidate_full.go", - "package main\n\nfunc CandidateFull() int { return 41 }\n")); - ASSERT_TRUE(write_go_file(g_incr_tmpdir, "force_full.go", - "package main\n\nfunc ForceFullRoute() int { return 42 }\n")); + /* Add a second manifest — incremental should pick it up */ + snprintf(path, sizeof(path), "%s/svc.yaml", tmpdir); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "apiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\n"); + fclose(f); - publish_cancel_ctx_t hook = { - .project = project, - .candidate = "CandidateFull", - }; - p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + /* Incremental re-index */ + cbm_config_t *cfg = incremental_test_config(tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook); - int rc = cbm_pipeline_run(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); cbm_pipeline_free(p); - live = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(live); - bool live_valid = cbm_store_check_integrity(live); - int nodes_after = cbm_store_count_nodes(live, project); - int helper_after = count_nodes_named(live, project, "Helper"); - int published_candidates = count_nodes_named(live, project, "CandidateFull"); - cbm_store_close(live); - bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path); + /* Verify both Resource nodes now present */ + s = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(s); + nodes = NULL; + count = 0; + cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count); + ASSERT_GTE(count, 2); + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); free(project); - cleanup_incremental_repo(); - - ASSERT_EQ(hook.calls, 1); - ASSERT_TRUE(hook.staging_existed); - ASSERT_TRUE(hook.staging_was_valid); - ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: new full output was staged */ - ASSERT_EQ(rc, -1); - ASSERT_TRUE(live_valid); - ASSERT_EQ(nodes_after, baseline_nodes); - ASSERT_EQ(helper_after, baseline_helper); - ASSERT_EQ(published_candidates, 0); - ASSERT_TRUE(staging_cleaned); + cbm_config_close(cfg); + th_rmtree(tmpdir); PASS(); } -/* The same contract applies to the incremental path: the modified file is - * present in a complete staging DB at the hook, but cancellation leaves the - * prior committed snapshot queryable and removes every staging artifact. */ -TEST(cancelled_incremental_reindex_preserves_committed_db) { - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); +TEST(incremental_kustomize_module_indexed) { + /* Verifies that a kustomization.yaml added after the initial full index + * gets a Module node via the incremental k8s pass. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_kust_incr_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("tmpdir"); } + char dbpath[512]; + snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); + char path[512]; + FILE *f; - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + /* Initial resource manifest (gives full index something to find) */ + snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n"); + fclose(f); + + /* Full index */ + cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); ASSERT_EQ(cbm_pipeline_run(p), 0); char *project = strdup(cbm_pipeline_project_name(p)); cbm_pipeline_free(p); - ASSERT_NOT_NULL(project); - - cbm_store_t *live = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(live); - ASSERT_TRUE(cbm_store_check_integrity(live)); - int baseline_nodes = cbm_store_count_nodes(live, project); - int baseline_helper = count_nodes_named(live, project, "Helper"); - ASSERT_GT(baseline_nodes, 0); - ASSERT_GT(baseline_helper, 0); - ASSERT_EQ(count_nodes_named(live, project, "CandidateIncremental"), 0); - cbm_store_close(live); - ASSERT_TRUE(write_go_file(g_incr_tmpdir, "helper.go", - "package main\n\n" - "func Helper() string { return \"hello\" }\n\n" - "func CandidateIncremental() int { return 43 }\n")); + /* Add kustomization.yaml */ + snprintf(path, sizeof(path), "%s/kustomization.yaml", tmpdir); + f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "apiVersion: kustomize.config.k8s.io/v1beta1\n" + "kind: Kustomization\n" + "resources:\n" + " - deploy.yaml\n"); + fclose(f); - publish_cancel_ctx_t hook = { - .project = project, - .candidate = "CandidateIncremental", - }; - p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + /* Incremental re-index */ + cbm_config_t *cfg = incremental_test_config(tmpdir); + ASSERT_NOT_NULL(cfg); + p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook); - int rc = cbm_pipeline_run(p); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_run(p), 0); cbm_pipeline_free(p); - live = cbm_store_open_path(g_incr_dbpath); - ASSERT_NOT_NULL(live); - bool live_valid = cbm_store_check_integrity(live); - int nodes_after = cbm_store_count_nodes(live, project); - int helper_after = count_nodes_named(live, project, "Helper"); - int published_candidates = count_nodes_named(live, project, "CandidateIncremental"); - cbm_store_close(live); - bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path); - - free(project); - cleanup_incremental_repo(); - - ASSERT_EQ(hook.calls, 1); - ASSERT_TRUE(hook.staging_existed); - ASSERT_TRUE(hook.staging_was_valid); - ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: changed output was staged */ - ASSERT_EQ(rc, -1); - ASSERT_TRUE(live_valid); - ASSERT_EQ(nodes_after, baseline_nodes); - ASSERT_EQ(helper_after, baseline_helper); - ASSERT_EQ(published_candidates, 0); - ASSERT_TRUE(staging_cleaned); - PASS(); -} - -TEST(backup_failed_publish_failure_preserves_final_sidecars) { - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); - } - - char final_path[512]; - char wal_path[544]; - char shm_path[544]; - char journal_path[544]; - snprintf(final_path, sizeof(final_path), "%s/blocked.db", g_incr_tmpdir); - snprintf(wal_path, sizeof(wal_path), "%s-wal", final_path); - snprintf(shm_path, sizeof(shm_path), "%s-shm", final_path); - snprintf(journal_path, sizeof(journal_path), "%s-journal", final_path); - - /* Invalid SQLite makes backup fail. The live sidecars may contain the only - * recoverable state, so the rebuilt generation must be refused without - * changing any member of the old database family. */ - ASSERT_EQ(th_write_file(final_path, "corrupt-main"), 0); - ASSERT_EQ(th_write_file(wal_path, "live-wal"), 0); - ASSERT_EQ(th_write_file(shm_path, "live-shm"), 0); - ASSERT_EQ(th_write_file(journal_path, "live-journal"), 0); + /* Verify Module node created for the kustomization overlay */ + cbm_store_t *s = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(s); + cbm_node_t *nodes = NULL; + int count = 0; + cbm_store_find_nodes_by_label(s, project, "Module", &nodes, &count); + bool found_kust = false; + for (int i = 0; i < count; i++) { + if (nodes[i].properties_json && strstr(nodes[i].properties_json, "kustomize")) { + found_kust = true; + break; + } + } + cbm_store_free_nodes(nodes, count); + cbm_store_close(s); + ASSERT_TRUE(found_kust); - publish_observe_ctx_t hook = {0}; - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook); - int rc = cbm_pipeline_run(p); - cbm_pipeline_free(p); + free(project); + cbm_config_close(cfg); + th_rmtree(tmpdir); + PASS(); +} - bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main"); - bool wal_preserved = pipeline_fixture_file_equals(wal_path, "live-wal"); - bool shm_preserved = pipeline_fixture_file_equals(shm_path, "live-shm"); - bool journal_preserved = pipeline_fixture_file_equals(journal_path, "live-journal"); +/* ── Index lock tests ───────────────────────────────────────────── */ - (void)cbm_unlink(final_path); - (void)cbm_unlink(wal_path); - (void)cbm_unlink(shm_path); - (void)cbm_unlink(journal_path); - cleanup_incremental_repo(); +TEST(pipeline_lock_try_acquire) { + /* First try-lock should succeed */ + ASSERT_TRUE(cbm_pipeline_try_lock()); + /* Second try-lock should fail (already held) */ + ASSERT_FALSE(cbm_pipeline_try_lock()); + /* Release, then re-acquire should succeed */ + cbm_pipeline_unlock(); + ASSERT_TRUE(cbm_pipeline_try_lock()); + cbm_pipeline_unlock(); + PASS(); +} - ASSERT_EQ(hook.calls, 1); - ASSERT_TRUE(hook.staging_was_valid); - ASSERT_TRUE(rc != 0); - ASSERT_TRUE(final_preserved); - ASSERT_TRUE(wal_preserved); - ASSERT_TRUE(shm_preserved); - ASSERT_TRUE(journal_preserved); +TEST(pipeline_lock_blocking) { + /* Lock, then unlock — basic sanity */ + cbm_pipeline_lock(); + cbm_pipeline_unlock(); + /* Should be immediately re-acquirable */ + cbm_pipeline_lock(); + cbm_pipeline_unlock(); PASS(); } -TEST(backup_failed_rename_failure_preserves_corrupt_main) { - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); +/* Thread function that tries to acquire the lock and records result */ +static atomic_int g_thread_acquired = 0; +static atomic_int g_thread_done = 0; + +static void *try_lock_thread(void *arg) { + (void)arg; + if (cbm_pipeline_try_lock()) { + atomic_store(&g_thread_acquired, 1); + cbm_pipeline_unlock(); + } else { + atomic_store(&g_thread_acquired, 0); } + atomic_store(&g_thread_done, 1); + return NULL; +} - char final_path[512]; - snprintf(final_path, sizeof(final_path), "%s/corrupt.db", g_incr_tmpdir); - ASSERT_EQ(th_write_file(final_path, "corrupt-main-before-rename"), 0); +TEST(pipeline_lock_contention) { + /* Main thread holds lock, spawned thread should fail try_lock */ + cbm_pipeline_lock(); + atomic_store(&g_thread_acquired, -1); + atomic_store(&g_thread_done, 0); - publish_observe_ctx_t observe = {0}; - publish_rename_fail_ctx_t rename_fail = {0}; - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &observe); - cbm_pipeline_set_rename_hook_for_tests(p, fail_publish_rename, &rename_fail); - int rc = cbm_pipeline_run(p); - cbm_pipeline_free(p); + cbm_thread_t tid; + int rc = cbm_thread_create(&tid, 0, try_lock_thread, NULL); + ASSERT_EQ(rc, 0); - bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main-before-rename"); - (void)cbm_unlink(final_path); - cleanup_incremental_repo(); + /* Wait for thread to finish */ + cbm_thread_join(&tid); - ASSERT_EQ(observe.calls, 1); - ASSERT_TRUE(observe.staging_was_valid); - ASSERT_EQ(rename_fail.calls, 1); - ASSERT_TRUE(rc != 0); - ASSERT_TRUE(final_preserved); + /* Thread should NOT have acquired the lock */ + ASSERT_EQ(atomic_load(&g_thread_acquired), 0); + cbm_pipeline_unlock(); PASS(); } -#ifdef __linux__ -static void cleanup_long_db_fixture(char *deep_dir, const char *root, const char *db_path, - const char *staging_path) { - (void)cbm_unlink(db_path); - (void)cbm_remove_db_sidecars(db_path); +TEST(pipeline_lock_release_allows_contender) { + /* Main thread acquires and releases, then spawned thread should succeed */ + cbm_pipeline_lock(); + cbm_pipeline_unlock(); - /* The unfixed full-dump path writes to the first 1023 bytes of the - * staging name. Remove that sibling so the RED test cleans up after - * itself as well as the fixed implementation. */ - if (staging_path && strlen(staging_path) >= CBM_SZ_1K) { - char truncated[CBM_SZ_1K]; - memcpy(truncated, staging_path, sizeof(truncated) - 1); - truncated[sizeof(truncated) - 1] = '\0'; - (void)cbm_unlink(truncated); - (void)cbm_remove_db_sidecars(truncated); - } + atomic_store(&g_thread_acquired, -1); + atomic_store(&g_thread_done, 0); - size_t root_len = strlen(root); - while (strlen(deep_dir) > root_len) { - (void)cbm_rmdir(deep_dir); - char *slash = strrchr(deep_dir, '/'); - if (!slash) { - break; - } - *slash = '\0'; - } - (void)cbm_rmdir(root); + cbm_thread_t tid; + int rc = cbm_thread_create(&tid, 0, try_lock_thread, NULL); + ASSERT_EQ(rc, 0); + cbm_thread_join(&tid); + + /* Thread SHOULD have acquired the lock */ + ASSERT_EQ(atomic_load(&g_thread_acquired), 1); + PASS(); } -TEST(full_reindex_preserves_exact_long_db_path) { - if (setup_incremental_repo() != 0) { - FAIL("setup failed"); - } - char *created_root = th_mktempdir("cbm_long_db"); - ASSERT_NOT_NULL(created_root); - char root[256]; - snprintf(root, sizeof(root), "%s", created_root); +/* ── Resource management & internal helper tests ─────────────────── */ - char deep_dir[1600]; - snprintf(deep_dir, sizeof(deep_dir), "%s", root); - static const char component[] = - "/segment_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; - while (strlen(deep_dir) < 1100) { - size_t used = strlen(deep_dir); - ASSERT_TRUE(used + sizeof(component) < sizeof(deep_dir)); - memcpy(deep_dir + used, component, sizeof(component)); +TEST(pipeline_empty_path) { + /* Empty string repo path — should handle gracefully */ + cbm_pipeline_t *p = cbm_pipeline_new("", NULL, CBM_MODE_FULL); + /* Implementation may return NULL or a valid pipeline with empty project name. + * Either behavior is acceptable — the key is no crash. */ + if (p) { + cbm_pipeline_free(p); } - ASSERT_TRUE(cbm_mkdir_p(deep_dir, 0755)); + PASS(); +} - char db_path[1600]; - int db_n = snprintf(db_path, sizeof(db_path), "%s/graph.db", deep_dir); - ASSERT_TRUE(db_n > CBM_SZ_1K && (size_t)db_n < sizeof(db_path)); +TEST(pipeline_project_name_content) { + /* Verify project name is derived from the repo_path */ + cbm_pipeline_t *p = cbm_pipeline_new("/home/user/my-project", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + const char *name = cbm_pipeline_project_name(p); + ASSERT_NOT_NULL(name); + ASSERT_TRUE(strlen(name) > 0); + /* Should contain "my-project" as part of the derived name */ + ASSERT_TRUE(strstr(name, "my-project") != NULL); + cbm_pipeline_free(p); + PASS(); +} - publish_observe_ctx_t hook = {0}; - cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, db_path, CBM_MODE_FULL); +TEST(pipeline_cancel_sets_flag) { + /* Verify cancel sets the flag so subsequent run exits early */ + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - char *project = strdup(cbm_pipeline_project_name(p)); - ASSERT_NOT_NULL(project); - cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook); + /* Cancel before run */ + cbm_pipeline_cancel(p); + /* Cancelled pipeline should return quickly (either -1 from cancel or from + * missing path — both are acceptable; key is no hang) */ int rc = cbm_pipeline_run(p); + ASSERT_EQ(rc, -1); cbm_pipeline_free(p); + PASS(); +} - int node_count = -1; - cbm_store_t *published = rc == 0 ? cbm_store_open_path_existing(db_path) : NULL; - if (published) { - node_count = cbm_store_count_nodes(published, project); - cbm_store_close(published); - } - bool stray_truncated_db = false; - if (strlen(hook.staging_path) >= CBM_SZ_1K) { - char truncated[CBM_SZ_1K]; - memcpy(truncated, hook.staging_path, sizeof(truncated) - 1); - truncated[sizeof(truncated) - 1] = '\0'; - stray_truncated_db = access(truncated, F_OK) == 0; - } +TEST(pipeline_double_cancel) { + /* Calling cancel twice should not crash */ + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_cancel(p); + cbm_pipeline_cancel(p); + cbm_pipeline_free(p); + PASS(); +} - free(project); - cleanup_long_db_fixture(deep_dir, root, db_path, hook.staging_path); - cleanup_incremental_repo(); +TEST(pipeline_double_free_prevention) { + /* free(NULL) after free should not crash. We can't truly double-free + * the same pointer, but we verify NULL is safe as documented. */ + cbm_pipeline_free(NULL); + cbm_pipeline_free(NULL); + PASS(); +} - ASSERT_EQ(hook.calls, 1); - ASSERT_TRUE(strlen(hook.staging_path) >= CBM_SZ_1K); - ASSERT_EQ(rc, 0); - ASSERT_GT(node_count, 0); - ASSERT_FALSE(stray_truncated_db); +TEST(pipeline_unit_threshold_setters_clamp_invalid_values) { + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + + cbm_pipeline_set_similarity_threshold(p, 0.7); + cbm_pipeline_set_httplink_min_confidence(p, 0.25); + cbm_pipeline_set_semantic_threshold(p, 0.75); + cbm_pipeline_set_githistory_min_coupling(p, 0.3); + cbm_pipeline_set_lsp_confidence_floor(p, 0.6); + ASSERT_TRUE(cbm_pipeline_similarity_threshold(p) == 0.7); + ASSERT_TRUE(cbm_pipeline_httplink_min_confidence(p) == 0.25); + ASSERT_TRUE(cbm_pipeline_semantic_threshold(p) == 0.75); + ASSERT_TRUE(cbm_pipeline_githistory_min_coupling(p) == 0.3); + ASSERT_TRUE(cbm_pipeline_lsp_confidence_floor(p) == 0.6); + + cbm_pipeline_set_similarity_threshold(p, -1.0); + cbm_pipeline_set_httplink_min_confidence(p, 0.0); + cbm_pipeline_set_semantic_threshold(p, 1.5); + cbm_pipeline_set_githistory_min_coupling(p, 2.0); + cbm_pipeline_set_lsp_confidence_floor(p, -0.1); + ASSERT_TRUE(cbm_pipeline_similarity_threshold(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_httplink_min_confidence(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_semantic_threshold(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_githistory_min_coupling(p) == 0.0); + ASSERT_TRUE(cbm_pipeline_lsp_confidence_floor(p) == 0.0); + + cbm_pipeline_free(p); PASS(); } -#endif -TEST(incremental_fast_preserves_mode_skipped_tools_dir) { - /* Regression: 2026-04-13. A fast-mode reindex after a full-mode index - * was silently destroying every file under FAST_SKIP_DIRS directories - * (`tools`, `scripts`, `bin`, `build`, `docs`, ...) by classifying them - * as deleted in find_deleted_files even though they still existed on - * disk. The Skyline graph lost packages/mcp/src/tools/ (18 files / ~500 - * nodes) mid-session when a concurrent /develop run obediently called - * mode='fast'. This test pins the additive semantics: lesser-mode - * reindexes must NOT delete files that are merely outside the current - * pass's discovery scope. Fix: find_deleted_files now stat()s each - * stored-but-missing file and only purges it if it is truly absent - * from disk. */ +TEST(pipeline_githistory_max_couplings_clamps_to_shared_range) { + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_githistory_max_couplings(p), CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS); + + cbm_pipeline_set_githistory_max_couplings(p, CBM_GITHISTORY_MAX_COUPLINGS_LIMIT); + ASSERT_EQ(cbm_pipeline_githistory_max_couplings(p), CBM_GITHISTORY_MAX_COUPLINGS_LIMIT); + + cbm_pipeline_set_githistory_max_couplings(p, 0); + ASSERT_EQ(cbm_pipeline_githistory_max_couplings(p), CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS); + + cbm_pipeline_set_githistory_max_couplings(p, CBM_GITHISTORY_MAX_COUPLINGS_LIMIT + 1); + ASSERT_EQ(cbm_pipeline_githistory_max_couplings(p), CBM_GITHISTORY_MAX_COUPLINGS_LIMIT); + + cbm_pipeline_free(p); + PASS(); +} + +TEST(pipeline_publish_kind_names_are_stable) { + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name(CBM_PIPELINE_PUBLISH_NONE), "none"); + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name(CBM_PIPELINE_PUBLISH_FULL), "full"); + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name(CBM_PIPELINE_PUBLISH_INCREMENTAL_NOOP), + "incremental_noop"); + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name(CBM_PIPELINE_PUBLISH_INCREMENTAL_EXACT), + "incremental_exact"); + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name(CBM_PIPELINE_PUBLISH_INCREMENTAL_OVERLAY), + "incremental_overlay"); + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name(CBM_PIPELINE_PUBLISH_INCREMENTAL_CONTAINMENT), + "incremental_containment"); + ASSERT_STR_EQ(cbm_pipeline_publish_kind_name((cbm_pipeline_publish_kind_t)999), "unknown"); + PASS(); +} + +TEST(pipeline_apply_config_sets_all_thresholds) { + enum { + PIPELINE_TEST_EXACT_MAX_CHANGED = 3, + PIPELINE_TEST_EXACT_MAX_AFFECTED = 9, + PIPELINE_TEST_GITHISTORY_MAX_COUPLINGS = 65536, + }; char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_modeskip_XXXXXX"); + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pipeline_cfg_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) { - FAIL("tmpdir"); + FAIL("cbm_mkdtemp failed"); } - char dbpath[512]; - snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); - char path[512]; - FILE *f; + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SIMILARITY_THRESHOLD, "0.71"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_HTTPLINK_MIN_CONFIDENCE, "0.26"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SEMANTIC_THRESHOLD, "0.76"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_GITHISTORY_MIN_COUPLING, "0.31"), 0); + char githistory_max_couplings[CBM_SZ_32]; + int n = snprintf(githistory_max_couplings, sizeof(githistory_max_couplings), "%d", + PIPELINE_TEST_GITHISTORY_MAX_COUPLINGS); + ASSERT(n >= 0 && (size_t)n < sizeof(githistory_max_couplings)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_GITHISTORY_MAX_COUPLINGS, githistory_max_couplings), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_LSP_CONFIDENCE_FLOOR, "0.61"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SIMILARITY_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SEMANTIC_EDGES_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_GITHISTORY_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_HTTPLINKS_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_EXTRACT_TIMEOUT_MS, "17000"), 0); + char max_changed[CBM_SZ_32]; + char max_affected[CBM_SZ_32]; + n = snprintf(max_changed, sizeof(max_changed), "%d", PIPELINE_TEST_EXACT_MAX_CHANGED); + ASSERT(n >= 0 && (size_t)n < sizeof(max_changed)); + n = snprintf(max_affected, sizeof(max_affected), "%d", PIPELINE_TEST_EXACT_MAX_AFFECTED); + ASSERT(n >= 0 && (size_t)n < sizeof(max_affected)); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_CHANGED_PATHS, max_changed), + 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS, max_affected), + 0); - /* main.go — root-level production code (visible in fast and full) */ - snprintf(path, sizeof(path), "%s/main.go", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "package main\n\nfunc main() {\n}\n"); - fclose(f); + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_apply_config(p, cfg); + + ASSERT_TRUE(cbm_pipeline_similarity_threshold(p) > 0.70); + ASSERT_TRUE(cbm_pipeline_similarity_threshold(p) < 0.72); + ASSERT_TRUE(cbm_pipeline_httplink_min_confidence(p) > 0.25); + ASSERT_TRUE(cbm_pipeline_httplink_min_confidence(p) < 0.27); + ASSERT_TRUE(cbm_pipeline_semantic_threshold(p) > 0.75); + ASSERT_TRUE(cbm_pipeline_semantic_threshold(p) < 0.77); + ASSERT_TRUE(cbm_pipeline_githistory_min_coupling(p) > 0.30); + ASSERT_TRUE(cbm_pipeline_githistory_min_coupling(p) < 0.32); + ASSERT_EQ(cbm_pipeline_githistory_max_couplings(p), PIPELINE_TEST_GITHISTORY_MAX_COUPLINGS); + ASSERT_TRUE(cbm_pipeline_lsp_confidence_floor(p) > 0.60); + ASSERT_TRUE(cbm_pipeline_lsp_confidence_floor(p) < 0.62); + ASSERT_FALSE(cbm_pipeline_similarity_enabled(p)); + ASSERT_FALSE(cbm_pipeline_semantic_edges_enabled(p)); + ASSERT_FALSE(cbm_pipeline_githistory_enabled(p)); + ASSERT_FALSE(cbm_pipeline_httplinks_enabled(p)); + ASSERT_EQ(cbm_pipeline_extract_timeout_micros(p), 17000000); + ASSERT_EQ(cbm_pipeline_exact_max_changed_paths(p), PIPELINE_TEST_EXACT_MAX_CHANGED); + ASSERT_EQ(cbm_pipeline_exact_max_affected_paths(p), PIPELINE_TEST_EXACT_MAX_AFFECTED); + ASSERT_TRUE(cbm_pipeline_incremental_derived_results_refresh_defers_exact_delta_reindexes(p)); + ASSERT_TRUE( + cbm_pipeline_incremental_derived_results_refresh_defers_all_incremental_reindexes(p)); + + ASSERT_NEQ(cbm_config_set(cfg, CBM_CONFIG_EXTRACT_TIMEOUT_MS, "1"), 0); + cbm_config_close(cfg); + ASSERT_EQ(th_set_raw_config_value(tmpdir, CBM_CONFIG_EXTRACT_TIMEOUT_MS, "1"), 0); + cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_extract_timeout_micros(p), + (int64_t)CBM_CONFIG_EXTRACT_TIMEOUT_DEFAULT_MS * 1000); + + ASSERT_NEQ(cbm_config_set(cfg, CBM_CONFIG_EXTRACT_TIMEOUT_MS, "999999"), 0); + cbm_config_close(cfg); + ASSERT_EQ(th_set_raw_config_value(tmpdir, CBM_CONFIG_EXTRACT_TIMEOUT_MS, "999999"), 0); + cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + cbm_pipeline_apply_config(p, cfg); + ASSERT_EQ(cbm_pipeline_extract_timeout_micros(p), + (int64_t)CBM_CONFIG_EXTRACT_TIMEOUT_DEFAULT_MS * 1000); - /* tools/util.go — production code under a FAST_SKIP_DIRS directory. - * Full mode indexes it; fast mode skips it via the discover.c heuristic. */ - char tools_dir[512]; - snprintf(tools_dir, sizeof(tools_dir), "%s/tools", tmpdir); - cbm_mkdir_p(tools_dir, 0755); - snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "package tools\n\nfunc Util() string {\n\treturn \"u\"\n}\n"); - fclose(f); + cbm_pipeline_free(p); + cbm_config_close(cfg); + rm_rf(tmpdir); + PASS(); +} - /* Step 1: full-mode index — both files should be present */ - cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); +TEST(pipeline_capability_gates_default_enabled) { + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - char *project = strdup(cbm_pipeline_project_name(p)); + ASSERT_TRUE(cbm_pipeline_similarity_enabled(p)); + ASSERT_TRUE(cbm_pipeline_semantic_edges_enabled(p)); + ASSERT_TRUE(cbm_pipeline_githistory_enabled(p)); + ASSERT_TRUE(cbm_pipeline_httplinks_enabled(p)); cbm_pipeline_free(p); + PASS(); +} - cbm_store_t *s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - cbm_node_t *tools_nodes_before = NULL; - int tools_count_before = 0; - cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_before, - &tools_count_before); - ASSERT_GT(tools_count_before, 0); /* full mode must see tools/util.go */ - cbm_store_free_nodes(tools_nodes_before, tools_count_before); - int total_before = cbm_store_count_nodes(s, project); - cbm_store_close(s); +TEST(pipeline_disabled_capabilities_skip_expensive_passes) { + if (setup_test_repo() != 0) { + FAIL("failed to create capability-gate repo"); + } + char db_path[CBM_PATH_MAX]; + int n = snprintf(db_path, sizeof(db_path), "%s/capabilities.db", g_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(db_path)); - /* Step 2: fast-mode reindex — tools/util.go MUST survive (additive semantics) */ - p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + cbm_config_t *cfg = cbm_config_open(g_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SIMILARITY_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_SEMANTIC_EDGES_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_GITHISTORY_ENABLED, "false"), 0); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_HTTPLINKS_ENABLED, "false"), 0); + + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL); ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - cbm_pipeline_free(p); + cbm_pipeline_apply_config(p, cfg); + pipeline_capture_logs_start(); + int rc = cbm_pipeline_run(p); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(rc, 0); + ASSERT_NOT_NULL(strstr(logs, "msg=pass.skip pass=githistory reason=disabled")); + ASSERT_NOT_NULL(strstr(logs, "msg=pass.skip pass=similarity reason=disabled")); + ASSERT_NOT_NULL(strstr(logs, "msg=pass.skip pass=semantic_edges reason=disabled")); + ASSERT_NOT_NULL(strstr(logs, "msg=pass.skip pass=httplinks reason=disabled")); - s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - cbm_node_t *tools_nodes_after = NULL; - int tools_count_after = 0; - cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_after, - &tools_count_after); - /* The critical assertion: tools/util.go nodes must still be present after - * a fast-mode reindex that skipped the tools/ directory. Before the fix, - * this was 0. */ - ASSERT_GT(tools_count_after, 0); - ASSERT_EQ(tools_count_after, tools_count_before); /* same nodes, untouched */ - cbm_store_free_nodes(tools_nodes_after, tools_count_after); + cbm_pipeline_free(p); + cbm_config_close(cfg); + teardown_test_repo(); + PASS(); +} - /* Sanity: total node count should not have collapsed by ~the size of tools/ */ - int total_after = cbm_store_count_nodes(s, project); - ASSERT_GTE(total_after, total_before); /* additive — never less */ - cbm_store_close(s); +TEST(pipeline_githistory_compute_overlaps_independent_postpasses) { + enum { PIPELINE_GITHISTORY_SYNCHRONOUS_WORKERS = 1, PIPELINE_GITHISTORY_THREADED_WORKERS = 2 }; + pipeline_env_snapshot_t workers_env = pipeline_env_save("CBM_WORKERS"); + if (setup_test_repo() != 0) { + FAIL("failed to create Git-history overlap repo"); + } + const char *const init_args[] = {"init", "-q", NULL}; + const char *const email_args[] = {"config", "user.email", "test@example.invalid", NULL}; + const char *const name_args[] = {"config", "user.name", "CBM Test", NULL}; + const char *const add_args[] = {"add", "main.go", "pkg/service.go", NULL}; + const char *const initial_commit_args[] = {"commit", "-q", "-m", "initial", NULL}; + const char *const changed_commit_args[] = {"commit", "-q", "-m", "changed", NULL}; + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, init_args), 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, email_args), 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, name_args), 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, add_args), 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, initial_commit_args), 0); + ASSERT_EQ(th_write_file(TH_PATH(g_tmpdir, "main.go"), + "package main\n\nfunc main() { println(\"changed\") }\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(g_tmpdir, "pkg/service.go"), + "package pkg\n\nfunc Serve() { println(\"changed\") }\n"), + 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, add_args), 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, changed_commit_args), 0); + /* Meet the production evidence floor so both schedules publish an edge. */ + ASSERT_EQ(th_write_file(TH_PATH(g_tmpdir, "main.go"), + "package main\n\nfunc main() { println(\"changed again\") }\n"), + 0); + ASSERT_EQ(th_write_file(TH_PATH(g_tmpdir, "pkg/service.go"), + "package pkg\n\nfunc Serve() { println(\"changed again\") }\n"), + 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, add_args), 0); + ASSERT_EQ(cbm_git_drain_command(g_tmpdir, changed_commit_args), 0); + + char synchronous_db[CBM_PATH_MAX]; + char threaded_db[CBM_PATH_MAX]; + int n = + snprintf(synchronous_db, sizeof(synchronous_db), "%s/githistory-synchronous.db", g_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(synchronous_db)); + n = snprintf(threaded_db, sizeof(threaded_db), "%s/githistory-threaded.db", g_tmpdir); + ASSERT(n >= 0 && (size_t)n < sizeof(threaded_db)); + + char *project = NULL; + int synchronous_rc = pipeline_run_with_worker_count( + g_tmpdir, synchronous_db, PIPELINE_GITHISTORY_SYNCHRONOUS_WORKERS, &project); + pipeline_capture_logs_start(); + int threaded_rc = pipeline_run_with_worker_count(g_tmpdir, threaded_db, + PIPELINE_GITHISTORY_THREADED_WORKERS, NULL); + const char *logs = pipeline_capture_logs_end(); + const char *history_start = strstr(logs, "msg=pass.start pass=githistory execution=threaded"); + const char *http_done = strstr(logs, "msg=pass.timing pass=httplinks"); + const char *history_done = strstr(logs, "msg=pass.done pass=githistory"); + bool scheduling_order_is_valid = history_start && http_done && history_done && + history_start < http_done && http_done < history_done; + + pipeline_env_restore(&workers_env); + ASSERT_EQ(synchronous_rc, 0); + ASSERT_EQ(threaded_rc, 0); + ASSERT_NOT_NULL(project); + ASSERT_TRUE(scheduling_order_is_valid); + + char *main_qn = cbm_pipeline_fqn_compute(project, "main.go", "__file__"); + char *service_qn = cbm_pipeline_fqn_compute(project, "pkg/service.go", "__file__"); + ASSERT_NOT_NULL(main_qn); + ASSERT_NOT_NULL(service_qn); + ASSERT_TRUE(pipeline_store_has_edge_between_qns(synchronous_db, project, main_qn, + "FILE_CHANGES_WITH", service_qn)); + ASSERT_TRUE(pipeline_store_has_edge_between_qns(threaded_db, project, main_qn, + "FILE_CHANGES_WITH", service_qn)); + + char diff_err[CBM_SZ_8K] = {0}; + int diff_rc = cbm_test_compare_canonical_graphs(synchronous_db, threaded_db, project, diff_err, + sizeof(diff_err)); + if (diff_rc != 0) { + printf(" [githistory:scheduling-diff] %s\n", diff_err); + } + free(main_qn); + free(service_qn); + free(project); + teardown_test_repo(); + ASSERT_EQ(diff_rc, 0); + PASS(); +} - /* Step 3: mutate main.go and fast reindex — forces dump_and_persist to - * run (instead of the noop early-return path that step 2 hit). This is - * the real dangerous path: the gbuf gets loaded, mutated for main.go, - * dumped back to disk. tools/util.go must survive THAT cycle, not just - * the trivial noop path. Audit finding from 2026-04-13. */ - snprintf(path, sizeof(path), "%s/main.go", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "package main\n\nfunc main() {\n\tprintln(\"changed\")\n}\n"); - fclose(f); - /* Bump mtime explicitly — some filesystems have coarse mtime resolution - * and the rewrite could land in the same tick as the original write. */ -#ifndef _WIN32 - struct stat mst; - if (stat(path, &mst) == 0) { - struct timespec times[2]; - times[0].tv_sec = mst.st_atime; - times[0].tv_nsec = 0; - times[1].tv_sec = mst.st_mtime + 5; - times[1].tv_nsec = 0; - utimensat(AT_FDCWD, path, times, 0); +TEST(pipeline_capability_combinations_have_unique_fingerprints) { + enum { PIPELINE_CAPABILITY_COMBINATIONS = 16 }; + char fingerprints[PIPELINE_CAPABILITY_COMBINATIONS][CBM_SZ_256]; + for (int mask = 0; mask < PIPELINE_CAPABILITY_COMBINATIONS; mask++) { + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_similarity_enabled(p, (mask & 1) != 0); + cbm_pipeline_set_semantic_edges_enabled(p, (mask & 2) != 0); + cbm_pipeline_set_githistory_enabled(p, (mask & 4) != 0); + cbm_pipeline_set_httplinks_enabled(p, (mask & 8) != 0); + ASSERT_EQ(cbm_pipeline_current_pass_fingerprint( + p, fingerprints[mask], sizeof(fingerprints[mask])), + CBM_STORE_OK); + cbm_pipeline_free(p); + for (int prior = 0; prior < mask; prior++) { + ASSERT_NEQ(strcmp(fingerprints[prior], fingerprints[mask]), 0); + } } -#endif /* !_WIN32 */ + PASS(); +} - p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); +TEST(pipeline_exact_delta_limits_keep_safe_defaults) { + enum { PIPELINE_TEST_EXACT_INVERTED_CHANGED = CBM_SZ_8 }; + ASSERT_EQ(CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS, CBM_SZ_32); + cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FAST); ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - cbm_pipeline_free(p); - s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - cbm_node_t *tools_nodes_run3 = NULL; - int tools_count_run3 = 0; - cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_run3, &tools_count_run3); - /* tools/util.go nodes must STILL be present after a fast reindex that - * actually ran the full dump_and_persist cycle (not the noop fast-path). */ - ASSERT_EQ(tools_count_run3, tools_count_before); - cbm_store_free_nodes(tools_nodes_run3, tools_count_run3); - cbm_store_close(s); + ASSERT_EQ(cbm_pipeline_exact_max_changed_paths(p), + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_CHANGED_PATHS); + ASSERT_EQ(cbm_pipeline_exact_max_affected_paths(p), + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS); - /* Step 4: actually delete tools/util.go from disk and full-reindex. - * Now it really is gone, so its nodes should be purged. This pins the - * other half of the contract: the stat-based check correctly identifies - * truly-deleted files as deleted. */ - snprintf(path, sizeof(path), "%s/tools/util.go", tmpdir); - unlink(path); + cbm_pipeline_set_exact_delta_limits(p, 0, -1); + ASSERT_EQ(cbm_pipeline_exact_max_changed_paths(p), + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_CHANGED_PATHS); + ASSERT_EQ(cbm_pipeline_exact_max_affected_paths(p), + CBM_PIPELINE_EXACT_DELTA_DEFAULT_MAX_AFFECTED_PATHS); + + cbm_pipeline_set_exact_delta_limits(p, PIPELINE_TEST_EXACT_INVERTED_CHANGED, + CBM_ALLOC_ONE); + ASSERT_EQ(cbm_pipeline_exact_max_changed_paths(p), PIPELINE_TEST_EXACT_INVERTED_CHANGED); + ASSERT_EQ(cbm_pipeline_exact_max_affected_paths(p), PIPELINE_TEST_EXACT_INVERTED_CHANGED); - p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); cbm_pipeline_free(p); + PASS(); +} - s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - cbm_node_t *tools_nodes_deleted = NULL; - int tools_count_deleted = 0; - cbm_store_find_nodes_by_file(s, project, "tools/util.go", &tools_nodes_deleted, - &tools_count_deleted); - ASSERT_EQ(tools_count_deleted, 0); /* truly deleted → purged */ - cbm_store_free_nodes(tools_nodes_deleted, tools_count_deleted); - cbm_store_close(s); +static const char *semantic_edge_props_for(cbm_gbuf_t *gb, const char *src_qn, + const char *dst_qn) { + const cbm_gbuf_node_t *src = cbm_gbuf_find_by_qn(gb, src_qn); + const cbm_gbuf_node_t *dst = cbm_gbuf_find_by_qn(gb, dst_qn); + if (!src || !dst) { + return NULL; + } + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (cbm_gbuf_find_edges_by_source_type(gb, src->id, "SEMANTICALLY_RELATED", &edges, + &edge_count) != 0) { + return NULL; + } + for (int i = 0; i < edge_count; i++) { + if (edges[i]->target_id == dst->id) { + return edges[i]->properties_json; + } + } + return NULL; +} - free(project); - th_rmtree(tmpdir); +static cbm_gbuf_t *build_semantic_order_graph(bool reverse_alpha_calls) { + cbm_gbuf_t *gb = cbm_gbuf_new("sem-order", "/tmp/sem-order"); + if (!gb) { + return NULL; + } + const char props[] = + "{\"signature\":\"(request: Request, item: Item) -> Response\"," + "\"return_type\":\"Response\",\"param_names\":[\"request\",\"item\"]," + "\"param_types\":[\"Request\",\"Item\"],\"bt\":\"validate item return response\"}"; + int64_t alpha = + cbm_gbuf_upsert_node(gb, "Function", "alpha_handler", "sem-order.alpha_handler", + "routes.py", 1, 20, props); + int64_t beta = cbm_gbuf_upsert_node(gb, "Function", "beta_handler", "sem-order.beta_handler", + "routes.py", 21, 40, props); + int64_t validate = + cbm_gbuf_upsert_node(gb, "Function", "validate_item", "sem-order.validate_item", + "helpers.py", 1, 5, "{\"signature\":\"(item)\"}"); + int64_t serialize = + cbm_gbuf_upsert_node(gb, "Function", "serialize_response", "sem-order.serialize_response", + "helpers.py", 6, 10, "{\"signature\":\"(response)\"}"); + if (alpha <= 0 || beta <= 0 || validate <= 0 || serialize <= 0) { + cbm_gbuf_free(gb); + return NULL; + } + if (reverse_alpha_calls) { + cbm_gbuf_insert_edge(gb, alpha, serialize, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, alpha, validate, "CALLS", "{}"); + } else { + cbm_gbuf_insert_edge(gb, alpha, validate, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, alpha, serialize, "CALLS", "{}"); + } + cbm_gbuf_insert_edge(gb, beta, validate, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, beta, serialize, "CALLS", "{}"); + return gb; +} + +TEST(pipeline_semantic_edges_independent_of_call_insertion_order) { + cbm_gbuf_t *gb_forward = build_semantic_order_graph(false); + cbm_gbuf_t *gb_reverse = build_semantic_order_graph(true); + ASSERT_NOT_NULL(gb_forward); + ASSERT_NOT_NULL(gb_reverse); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx_forward = { + .project_name = "sem-order", + .repo_path = "/tmp/sem-order", + .gbuf = gb_forward, + .cancelled = &cancelled, + .semantic_threshold = 0.01, + }; + cbm_pipeline_ctx_t ctx_reverse = ctx_forward; + ctx_reverse.gbuf = gb_reverse; + + ASSERT_EQ(cbm_pipeline_pass_semantic_edges(&ctx_forward), 0); + ASSERT_EQ(cbm_pipeline_pass_semantic_edges(&ctx_reverse), 0); + + const char *forward = + semantic_edge_props_for(gb_forward, "sem-order.alpha_handler", "sem-order.beta_handler"); + const char *reverse = + semantic_edge_props_for(gb_reverse, "sem-order.alpha_handler", "sem-order.beta_handler"); + ASSERT_NOT_NULL(forward); + ASSERT_NOT_NULL(reverse); + ASSERT_STR_EQ(forward, reverse); + + cbm_gbuf_free(gb_forward); + cbm_gbuf_free(gb_reverse); PASS(); } -TEST(incremental_k8s_manifest_indexed) { - /* Full index with a k8s manifest, then add a new manifest via incremental. - * Verifies that cbm_pipeline_pass_k8s() runs during incremental re-index. */ - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_k8s_incr_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) { - FAIL("tmpdir"); +static cbm_sem_corpus_t *build_semantic_worker_parity_corpus(int worker_count) { + enum { + SEM_PARITY_DOCS = 4, + SEM_PARITY_MAX_TOKENS = 7, + }; + char *tokens[SEM_PARITY_DOCS * SEM_PARITY_MAX_TOKENS] = { + "request", "validate", "item", "response", "json", "route", "status", + "request", "validate", "payload", "response", "json", "handler", "status", + "auth", "token", "validate", "request", "handler", "security", "status", + "auth", "token", "refresh", "response", "security", "handler", "json", + }; + int counts[SEM_PARITY_DOCS] = { + 7, + 7, + 7, + 7, + }; + cbm_sem_corpus_t *corpus = cbm_sem_corpus_new(); + if (!corpus) { + return NULL; } - char dbpath[512]; - snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); - char path[512]; - FILE *f; + if (!cbm_sem_corpus_add_docs_batch_with_workers(corpus, tokens, counts, SEM_PARITY_DOCS, + SEM_PARITY_MAX_TOKENS, worker_count)) { + cbm_sem_corpus_free(corpus); + return NULL; + } + cbm_sem_corpus_finalize_with_workers(corpus, worker_count); + return corpus; +} - /* Initial manifest */ - snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n"); - fclose(f); +TEST(pipeline_semantic_corpus_vectors_independent_of_worker_count) { + enum { + SEM_PARITY_SERIAL_WORKERS = 1, + SEM_PARITY_PARALLEL_WORKERS = 4, + }; + const float eps = 0.000001F; + cbm_sem_corpus_t *serial = build_semantic_worker_parity_corpus(SEM_PARITY_SERIAL_WORKERS); + cbm_sem_corpus_t *parallel = build_semantic_worker_parity_corpus(SEM_PARITY_PARALLEL_WORKERS); + ASSERT_NOT_NULL(serial); + ASSERT_NOT_NULL(parallel); + + ASSERT_EQ(cbm_sem_corpus_doc_count(serial), cbm_sem_corpus_doc_count(parallel)); + int token_count = cbm_sem_corpus_token_count(serial); + ASSERT_EQ(token_count, cbm_sem_corpus_token_count(parallel)); + const char *previous_token = NULL; + for (int i = 0; i < token_count; i++) { + const cbm_sem_vec_t *serial_vec = NULL; + const cbm_sem_vec_t *parallel_vec = NULL; + float serial_idf = 0.0F; + float parallel_idf = 0.0F; + const char *serial_token = cbm_sem_corpus_token_at(serial, i, &serial_vec, &serial_idf); + const char *parallel_token = + cbm_sem_corpus_token_at(parallel, i, ¶llel_vec, ¶llel_idf); + ASSERT_STR_EQ(serial_token, parallel_token); + if (previous_token) { + ASSERT(strcmp(previous_token, serial_token) <= 0); + } + previous_token = serial_token; + ASSERT_FLOAT_EQ(serial_idf, parallel_idf, eps); + ASSERT_NOT_NULL(serial_vec); + ASSERT_NOT_NULL(parallel_vec); + for (int d = 0; d < CBM_SEM_DIM; d++) { + ASSERT_FLOAT_EQ(serial_vec->v[d], parallel_vec->v[d], eps); + } + } - /* Full index */ - cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - char *project = strdup(cbm_pipeline_project_name(p)); - cbm_pipeline_free(p); + cbm_sem_corpus_free(serial); + cbm_sem_corpus_free(parallel); + PASS(); +} - /* Verify Resource node created by full index */ - cbm_store_t *s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - cbm_node_t *nodes = NULL; - int count = 0; - cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count); - ASSERT_GT(count, 0); - cbm_store_free_nodes(nodes, count); - cbm_store_close(s); +TEST(pipeline_semantic_corpus_add_doc_reserves_without_losing_docs) { + enum { + SEM_RESERVE_DOCS = 70, + SEM_RESERVE_TOKEN_COUNT = 2, + }; + const char *tokens[SEM_RESERVE_TOKEN_COUNT] = {"alpha", "beta"}; + cbm_sem_corpus_t *corpus = cbm_sem_corpus_new(); + ASSERT_NOT_NULL(corpus); - /* Add a second manifest — incremental should pick it up */ - snprintf(path, sizeof(path), "%s/svc.yaml", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "apiVersion: v1\nkind: Service\nmetadata:\n name: my-svc\n"); - fclose(f); + for (int i = 0; i < SEM_RESERVE_DOCS; i++) { + cbm_sem_corpus_add_doc(corpus, tokens, SEM_RESERVE_TOKEN_COUNT); + } - /* Incremental re-index */ - p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - cbm_pipeline_free(p); + ASSERT_EQ(cbm_sem_corpus_doc_count(corpus), SEM_RESERVE_DOCS); + ASSERT_EQ(cbm_sem_corpus_token_count(corpus), SEM_RESERVE_TOKEN_COUNT); + ASSERT_GTE(cbm_sem_corpus_token_id(corpus, "alpha"), 0); + ASSERT_GTE(cbm_sem_corpus_token_id(corpus, "beta"), 0); - /* Verify both Resource nodes now present */ - s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - nodes = NULL; - count = 0; - cbm_store_find_nodes_by_label(s, project, "Resource", &nodes, &count); - ASSERT_GTE(count, 2); - cbm_store_free_nodes(nodes, count); - cbm_store_close(s); + cbm_sem_corpus_free(corpus); + PASS(); +} - free(project); - th_rmtree(tmpdir); +TEST(pipeline_semantic_batch_rejects_invalid_token_stride) { + char *tokens[1] = {"alpha"}; + int counts[1] = {1}; + cbm_sem_corpus_t *corpus = cbm_sem_corpus_new(); + ASSERT_NOT_NULL(corpus); + + ASSERT_FALSE(cbm_sem_corpus_add_docs_batch_with_workers(corpus, tokens, counts, 1, 0, 1)); + + ASSERT_EQ(cbm_sem_corpus_doc_count(corpus), 0); + ASSERT_EQ(cbm_sem_corpus_token_count(corpus), 0); + + cbm_sem_corpus_free(corpus); PASS(); } -TEST(incremental_kustomize_module_indexed) { - /* Verifies that a kustomization.yaml added after the initial full index - * gets a Module node via the incremental k8s pass. */ - char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_kust_incr_XXXXXX"); - if (!cbm_mkdtemp(tmpdir)) { - FAIL("tmpdir"); +TEST(pipeline_semantic_corpus_accepts_nonuniform_docs_beyond_legacy_stride) { + enum { + SEM_LONG_DOC_TOKENS = 600, + SEM_SHORT_DOC_TOKENS = 1, + }; + char **long_doc = calloc(SEM_LONG_DOC_TOKENS, sizeof(*long_doc)); + ASSERT_NOT_NULL(long_doc); + for (int i = 0; i < SEM_LONG_DOC_TOKENS; i++) { + long_doc[i] = malloc(CBM_SZ_32); + ASSERT_NOT_NULL(long_doc[i]); + snprintf(long_doc[i], CBM_SZ_32, "semantic_token_%d", i); } - char dbpath[512]; - snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); - char path[512]; - FILE *f; + char *short_doc[SEM_SHORT_DOC_TOKENS] = {"short_doc_token"}; + char **docs[] = {long_doc, short_doc}; + int counts[] = {SEM_LONG_DOC_TOKENS, SEM_SHORT_DOC_TOKENS}; + + cbm_sem_corpus_t *corpus = cbm_sem_corpus_new(); + ASSERT_NOT_NULL(corpus); + ASSERT_TRUE(cbm_sem_corpus_add_doc_arrays_with_workers(corpus, docs, counts, 2, 2)); + + ASSERT_EQ(cbm_sem_corpus_doc_count(corpus), 2); + ASSERT_EQ(cbm_sem_corpus_token_count(corpus), + SEM_LONG_DOC_TOKENS + SEM_SHORT_DOC_TOKENS); + ASSERT_GTE(cbm_sem_corpus_token_id(corpus, "semantic_token_0"), 0); + ASSERT_GTE(cbm_sem_corpus_token_id(corpus, "semantic_token_599"), 0); + ASSERT_GTE(cbm_sem_corpus_token_id(corpus, "short_doc_token"), 0); + + cbm_sem_corpus_free(corpus); + for (int i = 0; i < SEM_LONG_DOC_TOKENS; i++) { + free(long_doc[i]); + } + free(long_doc); + PASS(); +} - /* Initial resource manifest (gives full index something to find) */ - snprintf(path, sizeof(path), "%s/deploy.yaml", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: my-app\n"); - fclose(f); +TEST(pipeline_semantic_batch_rejects_nonempty_corpus_without_reordering_existing_ids) { + const char *existing[] = {"zeta"}; + char *new_doc[] = {"alpha"}; + char **docs[] = {new_doc}; + int counts[] = {1}; + cbm_sem_corpus_t *corpus = cbm_sem_corpus_new(); + ASSERT_NOT_NULL(corpus); + cbm_sem_corpus_add_doc(corpus, existing, 1); + ASSERT_EQ(cbm_sem_corpus_token_id(corpus, "zeta"), 0); - /* Full index */ - cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - char *project = strdup(cbm_pipeline_project_name(p)); - cbm_pipeline_free(p); + ASSERT_FALSE(cbm_sem_corpus_add_doc_arrays_with_workers(corpus, docs, counts, 1, 1)); - /* Add kustomization.yaml */ - snprintf(path, sizeof(path), "%s/kustomization.yaml", tmpdir); - f = fopen(path, "w"); - ASSERT_NOT_NULL(f); - fprintf(f, "apiVersion: kustomize.config.k8s.io/v1beta1\n" - "kind: Kustomization\n" - "resources:\n" - " - deploy.yaml\n"); - fclose(f); + ASSERT_EQ(cbm_sem_corpus_doc_count(corpus), 1); + ASSERT_EQ(cbm_sem_corpus_token_count(corpus), 1); + ASSERT_EQ(cbm_sem_corpus_token_id(corpus, "zeta"), 0); + ASSERT_EQ(cbm_sem_corpus_token_id(corpus, "alpha"), CBM_NOT_FOUND); - /* Incremental re-index */ - p = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - ASSERT_EQ(cbm_pipeline_run(p), 0); - cbm_pipeline_free(p); + cbm_sem_corpus_free(corpus); + PASS(); +} - /* Verify Module node created for the kustomization overlay */ - cbm_store_t *s = cbm_store_open_path(dbpath); - ASSERT_NOT_NULL(s); - cbm_node_t *nodes = NULL; - int count = 0; - cbm_store_find_nodes_by_label(s, project, "Module", &nodes, &count); - bool found_kust = false; - for (int i = 0; i < count; i++) { - if (nodes[i].properties_json && strstr(nodes[i].properties_json, "kustomize")) { - found_kust = true; - break; - } +TEST(pipeline_semantic_edges_tokenize_complete_long_metadata) { + enum { + SEM_LONG_METADATA_DISTINCT_TOKENS = 600, + SEM_LONG_METADATA_ARRAY_ITEMS = 40, + SEM_LONG_METADATA_JSON_CAP = CBM_SZ_32K, + }; + char *props = malloc(SEM_LONG_METADATA_JSON_CAP); + ASSERT_NOT_NULL(props); + size_t used = 0; + int written = snprintf(props, SEM_LONG_METADATA_JSON_CAP, "{\"docstring\":\""); + ASSERT_GT(written, 0); + used = (size_t)written; + for (int i = 0; i < SEM_LONG_METADATA_DISTINCT_TOKENS; i++) { + written = snprintf(props + used, (size_t)SEM_LONG_METADATA_JSON_CAP - used, + "semantic_unique_%d ", i); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, (size_t)SEM_LONG_METADATA_JSON_CAP - used); + used += (size_t)written; } - cbm_store_free_nodes(nodes, count); - cbm_store_close(s); - ASSERT_TRUE(found_kust); + written = snprintf(props + used, (size_t)SEM_LONG_METADATA_JSON_CAP - used, + "\",\"param_names\":["); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, (size_t)SEM_LONG_METADATA_JSON_CAP - used); + used += (size_t)written; + for (int i = 0; i < SEM_LONG_METADATA_ARRAY_ITEMS; i++) { + written = snprintf(props + used, (size_t)SEM_LONG_METADATA_JSON_CAP - used, + "%s\"arrayitem%03d\"", i == 0 ? "" : ",", i); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, (size_t)SEM_LONG_METADATA_JSON_CAP - used); + used += (size_t)written; + } + written = snprintf(props + used, (size_t)SEM_LONG_METADATA_JSON_CAP - used, + "],\"bt\":\"neutral neutral neutral throw\"}"); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, (size_t)SEM_LONG_METADATA_JSON_CAP - used); - free(project); - th_rmtree(tmpdir); + cbm_gbuf_t *gb = cbm_gbuf_new("sem-long", "/tmp/sem-long"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "long_metadata", + "sem-long.long_metadata", "long.py", 1, 2, props), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "peer", "sem-long.peer", "peer.py", 1, 2, + "{\"docstring\":\"peer\"}"), + 0); + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "sem-long", + .repo_path = "/tmp/sem-long", + .gbuf = gb, + .cancelled = &cancelled, + .semantic_threshold = 0.01, + }; + + pipeline_capture_logs_start(); + ASSERT_EQ(cbm_pipeline_pass_semantic_edges(&ctx), 0); + const char *logs = pipeline_capture_logs_end(); + const char *marker = strstr(logs, "pass.semantic.token_vectors count="); + ASSERT_NOT_NULL(marker); + marker += strlen("pass.semantic.token_vectors count="); + char *end = NULL; + long token_count = strtol(marker, &end, 10); + ASSERT_TRUE(end != marker); + ASSERT_GTE(token_count, + SEM_LONG_METADATA_DISTINCT_TOKENS + SEM_LONG_METADATA_ARRAY_ITEMS); + + cbm_gbuf_free(gb); + free(props); PASS(); } -/* ── Index lock tests ───────────────────────────────────────────── */ +TEST(pipeline_semantic_edges_tokenize_escaped_json_metadata) { + /* Faithful shape from scripts/test_mcp_interactive.py::read_json_lines: + * quoted type annotations become JSON escapes in signature/param_types, + * and square brackets inside the quoted values are data, not array ends. */ + const char props[] = + "{\"signature\":\"(stream: BinaryIO, responses: " + "\\\"queue.Queue[dict[str, Any]]\\\", sig_tail_canary)\"," + "\"return_type\":\"None\"," + "\"param_types\":[\"BinaryIO\",\"\\\"queue.Queue[dict[str, Any]]\\\"\"," + "\"array_tail_canary\"]," + "\"docstring\":\"semantic_after_escaped_quote\"," + "\"bt\":\"array_after_escaped_quote\"}"; + cbm_gbuf_t *gb = cbm_gbuf_new("sem-escaped", "/tmp/sem-escaped"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "read_json_lines", + "sem-escaped.read_json_lines", "interactive.py", 1, 12, props), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "peer", "sem-escaped.peer", "peer.py", 1, 2, + "{\"docstring\":\"peer\"}"), + 0); + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "sem-escaped", + .repo_path = "/tmp/sem-escaped", + .gbuf = gb, + .cancelled = &cancelled, + .semantic_threshold = 0.01, + }; -TEST(pipeline_lock_try_acquire) { - /* First try-lock should succeed */ - ASSERT_TRUE(cbm_pipeline_try_lock()); - /* Second try-lock should fail (already held) */ - ASSERT_FALSE(cbm_pipeline_try_lock()); - /* Release, then re-acquire should succeed */ - cbm_pipeline_unlock(); - ASSERT_TRUE(cbm_pipeline_try_lock()); - cbm_pipeline_unlock(); + pipeline_capture_logs_start(); + int rc = cbm_pipeline_pass_semantic_edges(&ctx); + const char *logs = pipeline_capture_logs_end(); + ASSERT_EQ(rc, 0); + ASSERT_NULL(strstr(logs, "pass.semantic.tokenize_failed")); + const char *marker = strstr(logs, "pass.semantic.token_vectors count="); + ASSERT_NOT_NULL(marker); + marker += strlen("pass.semantic.token_vectors count="); + char *end = NULL; + long token_count = strtol(marker, &end, 10); + ASSERT_TRUE(end != marker); + /* Escaped quotes must not truncate the signature at responses, and the + * first ']' inside dict[str, Any] must not terminate param_types. */ + ASSERT_GTE(token_count, 23); + + cbm_gbuf_free(gb); PASS(); } -TEST(pipeline_lock_blocking) { - /* Lock, then unlock — basic sanity */ - cbm_pipeline_lock(); - cbm_pipeline_unlock(); - /* Should be immediately re-acquirable */ - cbm_pipeline_lock(); - cbm_pipeline_unlock(); +TEST(pipeline_semantic_edges_reports_noisy_bucket_partial_results) { + enum { + SEM_NOISY_BUCKET_FUNCTIONS = 205, + SEM_NOISY_BUCKET_SHARED_TOKENS = 256, + SEM_NOISY_BUCKET_JSON_CAP = CBM_SZ_16K, + }; + char *props = malloc(SEM_NOISY_BUCKET_JSON_CAP); + ASSERT_NOT_NULL(props); + int written = snprintf(props, SEM_NOISY_BUCKET_JSON_CAP, "{\"docstring\":\""); + ASSERT_GT(written, 0); + size_t used = (size_t)written; + for (int i = 0; i < SEM_NOISY_BUCKET_SHARED_TOKENS; i++) { + written = snprintf(props + used, (size_t)SEM_NOISY_BUCKET_JSON_CAP - used, + "shared_semantic_%d ", i); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, (size_t)SEM_NOISY_BUCKET_JSON_CAP - used); + used += (size_t)written; + } + written = snprintf(props + used, (size_t)SEM_NOISY_BUCKET_JSON_CAP - used, "\"}"); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, (size_t)SEM_NOISY_BUCKET_JSON_CAP - used); + + cbm_gbuf_t *gb = cbm_gbuf_new("sem-noisy", "/tmp/sem-noisy"); + ASSERT_NOT_NULL(gb); + for (int i = 0; i < SEM_NOISY_BUCKET_FUNCTIONS; i++) { + char qualified_name[CBM_SZ_128]; + written = snprintf(qualified_name, sizeof(qualified_name), "sem-noisy.clone_%d", i); + ASSERT_GT(written, 0); + ASSERT_LT((size_t)written, sizeof(qualified_name)); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "clone", qualified_name, "clone.py", 1, 2, + props), + 0); + } + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "sem-noisy", + .repo_path = "/tmp/sem-noisy", + .gbuf = gb, + .cancelled = &cancelled, + .semantic_threshold = 0.01, + }; + + pipeline_capture_logs_start(); + ASSERT_EQ(cbm_pipeline_pass_semantic_edges(&ctx), 0); + const char *logs = pipeline_capture_logs_end(); + ASSERT_NOT_NULL(strstr(logs, "pass.semantic.candidates_partial")); + const char *noisy = strstr(logs, "noisy_bucket_visits="); + ASSERT_NOT_NULL(noisy); + noisy += strlen("noisy_bucket_visits="); + char *end = NULL; + unsigned long long noisy_visits = strtoull(noisy, &end, 10); + ASSERT_TRUE(end != noisy); + ASSERT_GT(noisy_visits, 0); + ASSERT_NOT_NULL(strstr(logs, "unscored_candidates=")); + + cbm_gbuf_free(gb); + free(props); PASS(); } -/* Thread function that tries to acquire the lock and records result */ -static atomic_int g_thread_acquired = 0; -static atomic_int g_thread_done = 0; +TEST(pipeline_semantic_candidate_rank_prefers_band_evidence_canonically) { + cbm_semantic_candidate_t candidates[] = { + {.function_index = 40, .band_matches = 1}, {.function_index = 30, .band_matches = 4}, + {.function_index = 20, .band_matches = 4}, {.function_index = 10, .band_matches = 2}, + {.function_index = 50, .band_matches = 3}, + }; + cbm_semantic_candidate_t permuted[] = { + candidates[SKIP_ONE], candidates[4], candidates[0], candidates[3], candidates[2], + }; + const int expected[] = {20, 30, 50}; -static void *try_lock_thread(void *arg) { - (void)arg; - if (cbm_pipeline_try_lock()) { - atomic_store(&g_thread_acquired, 1); - cbm_pipeline_unlock(); - } else { - atomic_store(&g_thread_acquired, 0); + ASSERT_EQ(cbm_pipeline_rank_semantic_candidates(candidates, 5, 3), 3); + ASSERT_EQ(cbm_pipeline_rank_semantic_candidates(permuted, 5, 3), 3); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(candidates[i].function_index, expected[i]); + ASSERT_EQ(permuted[i].function_index, expected[i]); } - atomic_store(&g_thread_done, 1); - return NULL; + ASSERT_EQ(cbm_pipeline_rank_semantic_candidates(candidates, 5, 0), 0); + ASSERT_EQ(cbm_pipeline_rank_semantic_candidates(NULL, 5, 3), 0); + PASS(); } -TEST(pipeline_lock_contention) { - /* Main thread holds lock, spawned thread should fail try_lock */ - cbm_pipeline_lock(); - atomic_store(&g_thread_acquired, -1); - atomic_store(&g_thread_done, 0); +static const cbm_config_entry_t *find_config_entry(const char *key) { + for (int i = 0; CBM_CONFIG_REGISTRY[i].key; i++) { + if (strcmp(CBM_CONFIG_REGISTRY[i].key, key) == 0) { + return &CBM_CONFIG_REGISTRY[i]; + } + } + return NULL; +} - cbm_thread_t tid; - int rc = cbm_thread_create(&tid, 0, try_lock_thread, NULL); - ASSERT_EQ(rc, 0); +TEST(config_registry_includes_mcp_timeout_knobs) { + const cbm_config_entry_t *idle = find_config_entry("store_idle_timeout_s"); + ASSERT_NOT_NULL(idle); + ASSERT_STR_EQ(idle->default_val, "60"); + ASSERT_STR_EQ(idle->category, "MCP"); - /* Wait for thread to finish */ - cbm_thread_join(&tid); + const cbm_config_entry_t *validate = find_config_entry("db_validate_busy_timeout_ms"); + ASSERT_NOT_NULL(validate); + ASSERT_STR_EQ(validate->default_val, "1000"); + ASSERT_STR_EQ(validate->category, "MCP"); - /* Thread should NOT have acquired the lock */ - ASSERT_EQ(atomic_load(&g_thread_acquired), 0); - cbm_pipeline_unlock(); + const cbm_config_entry_t *update = find_config_entry("update_check_timeout_s"); + ASSERT_NOT_NULL(update); + ASSERT_STR_EQ(update->default_val, "5"); + ASSERT_STR_EQ(update->category, "MCP"); PASS(); } -TEST(pipeline_lock_release_allows_contender) { - /* Main thread acquires and releases, then spawned thread should succeed */ - cbm_pipeline_lock(); - cbm_pipeline_unlock(); +TEST(config_registry_includes_incremental_reindex_policy) { + const cbm_config_entry_t *entry = find_config_entry(CBM_CONFIG_INCREMENTAL_REINDEX); + ASSERT_NOT_NULL(entry); + ASSERT_STR_EQ(CBM_CONFIG_INCREMENTAL_REINDEX_DEFAULT, "always"); + ASSERT_STR_EQ(entry->default_val, CBM_CONFIG_INCREMENTAL_REINDEX_DEFAULT); + ASSERT_STR_EQ(entry->category, "Indexing"); + ASSERT_STR_EQ(entry->range, "always|full_rebuild|fast_mode_indexes_only"); + ASSERT_NOT_NULL(strstr(entry->guidance, "Every edit triggers a reindex")); + ASSERT_NOT_NULL(strstr(entry->guidance, "preserving correctness")); + PASS(); +} - atomic_store(&g_thread_acquired, -1); - atomic_store(&g_thread_done, 0); +TEST(config_registry_includes_extract_timeout) { + const cbm_config_entry_t *entry = find_config_entry(CBM_CONFIG_EXTRACT_TIMEOUT_MS); + ASSERT_NOT_NULL(entry); + ASSERT_STR_EQ(entry->default_val, CBM_CONFIG_EXTRACT_TIMEOUT_DEFAULT); + ASSERT_STR_EQ(entry->category, "Indexing"); + ASSERT_STR_EQ(entry->range, "100-120000"); + PASS(); +} - cbm_thread_t tid; - int rc = cbm_thread_create(&tid, 0, try_lock_thread, NULL); - ASSERT_EQ(rc, 0); - cbm_thread_join(&tid); +TEST(config_registry_includes_overlay_publish_policy) { + const cbm_config_entry_t *entry = find_config_entry(CBM_CONFIG_OVERLAY_PUBLISH); + ASSERT_NOT_NULL(entry); + ASSERT_STR_EQ(entry->default_val, CBM_CONFIG_OVERLAY_PUBLISH_OFF); + ASSERT_STR_EQ(entry->category, "Indexing"); + ASSERT_STR_EQ(entry->range, "off|small_deltas"); + PASS(); +} - /* Thread SHOULD have acquired the lock */ - ASSERT_EQ(atomic_load(&g_thread_acquired), 1); +TEST(config_registry_includes_overlay_compaction_policy) { + const cbm_config_entry_t *policy = + find_config_entry(CBM_CONFIG_OVERLAY_COMPACTION_POLICY); + ASSERT_NOT_NULL(policy); + ASSERT_STR_EQ(policy->default_val, CBM_CONFIG_OVERLAY_COMPACTION_POLICY_MANUAL); + ASSERT_STR_EQ(policy->category, "Indexing"); + ASSERT_STR_EQ(policy->range, "manual|after_publish"); + + const cbm_config_entry_t *max_generations = + find_config_entry(CBM_CONFIG_OVERLAY_COMPACTION_MAX_GENERATIONS); + ASSERT_NOT_NULL(max_generations); + ASSERT_STR_EQ(max_generations->default_val, + CBM_CONFIG_OVERLAY_COMPACTION_DEFAULT_MAX_GENERATIONS); + ASSERT_STR_EQ(max_generations->category, "Indexing"); + ASSERT_STR_EQ(max_generations->range, "1-256"); PASS(); } -/* ── Resource management & internal helper tests ─────────────────── */ +TEST(config_registry_includes_incremental_exact_frontier_caps) { + const cbm_config_entry_t *changed = + find_config_entry(CBM_CONFIG_INCREMENTAL_EXACT_MAX_CHANGED_PATHS); + ASSERT_NOT_NULL(changed); + ASSERT_STR_EQ(changed->default_val, CBM_CONFIG_INCREMENTAL_EXACT_DEFAULT_MAX_CHANGED_PATHS); + ASSERT_STR_EQ(changed->category, "Indexing"); + ASSERT_STR_EQ(changed->range, "1-100000"); + + const cbm_config_entry_t *affected = + find_config_entry(CBM_CONFIG_INCREMENTAL_EXACT_MAX_AFFECTED_PATHS); + ASSERT_NOT_NULL(affected); + ASSERT_STR_EQ(affected->default_val, CBM_CONFIG_INCREMENTAL_EXACT_DEFAULT_MAX_AFFECTED_PATHS); + ASSERT_STR_EQ(affected->default_val, "32"); + ASSERT_STR_EQ(affected->category, "Indexing"); + ASSERT_STR_EQ(affected->range, "1-100000"); + ASSERT_NOT_NULL(strstr(affected->guidance, "does not bound total indexing cost")); + ASSERT_NOT_NULL(strstr(affected->guidance, "Default 32")); -TEST(pipeline_empty_path) { - /* Empty string repo path — should handle gracefully */ - cbm_pipeline_t *p = cbm_pipeline_new("", NULL, CBM_MODE_FULL); - /* Implementation may return NULL or a valid pipeline with empty project name. - * Either behavior is acceptable — the key is no crash. */ - if (p) { - cbm_pipeline_free(p); - } PASS(); } -TEST(pipeline_project_name_content) { - /* Verify project name is derived from the repo_path */ - cbm_pipeline_t *p = cbm_pipeline_new("/home/user/my-project", NULL, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - const char *name = cbm_pipeline_project_name(p); - ASSERT_NOT_NULL(name); - ASSERT_TRUE(strlen(name) > 0); - /* Should contain "my-project" as part of the derived name */ - ASSERT_TRUE(strstr(name, "my-project") != NULL); - cbm_pipeline_free(p); +TEST(config_registry_includes_incremental_derived_results_refresh_policy) { + const cbm_config_entry_t *entry = + find_config_entry(CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH); + ASSERT_NOT_NULL(entry); + ASSERT_NULL(find_config_entry("incremental_derived_refresh")); + ASSERT_STR_EQ(CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFAULT, + "defer_all_incremental_reindexes"); + ASSERT_STR_EQ(entry->default_val, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_DEFAULT); + ASSERT_STR_EQ(entry->category, "Indexing"); + ASSERT_STR_EQ(entry->range, + "at_publish|defer_exact_delta_reindexes|defer_all_incremental_reindexes"); + ASSERT_NOT_NULL(strstr(entry->description, "semantic edges")); + ASSERT_NOT_NULL(strstr(entry->description, "similarity edges")); + ASSERT_NOT_NULL(strstr(entry->description, "architecture")); + ASSERT_NOT_NULL(strstr(entry->description, "routes")); PASS(); } -TEST(pipeline_cancel_sets_flag) { - /* Verify cancel sets the flag so subsequent run exits early */ - cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - /* Cancel before run */ - cbm_pipeline_cancel(p); - /* Cancelled pipeline should return quickly (either -1 from cancel or from - * missing path — both are acceptable; key is no hang) */ - int rc = cbm_pipeline_run(p); - ASSERT_EQ(rc, -1); - cbm_pipeline_free(p); +TEST(config_registry_includes_githistory_max_couplings) { + const cbm_config_entry_t *entry = find_config_entry(CBM_CONFIG_GITHISTORY_MAX_COUPLINGS); + ASSERT_NOT_NULL(entry); + ASSERT_STR_EQ(entry->default_val, CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS_STR); + ASSERT_EQ(atoi(entry->default_val), CBM_GITHISTORY_DEFAULT_MAX_COUPLINGS); + ASSERT_STR_EQ(entry->category, "Similarity"); + ASSERT_STR_EQ(entry->range, "1-" CBM_GITHISTORY_MAX_COUPLINGS_LIMIT_STR); + ASSERT_EQ(atoi(CBM_GITHISTORY_MAX_COUPLINGS_LIMIT_STR), CBM_GITHISTORY_MAX_COUPLINGS_LIMIT); + ASSERT_NOT_NULL(strstr(entry->guidance, "partial")); + ASSERT_NOT_NULL(strstr(entry->guidance, CBM_STRINGIFY(CBM_GITHISTORY_HISTORY_COMMIT_LIMIT))); + ASSERT_NOT_NULL(strstr(entry->guidance, CBM_STRINGIFY(CBM_GITHISTORY_MAX_FILES_PER_COMMIT))); PASS(); } -TEST(pipeline_double_cancel) { - /* Calling cancel twice should not crash */ - cbm_pipeline_t *p = cbm_pipeline_new("/tmp/nonexistent", NULL, CBM_MODE_FULL); - ASSERT_NOT_NULL(p); - cbm_pipeline_cancel(p); - cbm_pipeline_cancel(p); - cbm_pipeline_free(p); +TEST(config_registry_includes_rank_refresh_policy) { + const cbm_config_entry_t *entry = find_config_entry(CBM_CONFIG_RANK_REFRESH); + ASSERT_NOT_NULL(entry); + ASSERT_STR_EQ(CBM_RANK_REFRESH_DEFAULT, "defer_all_incremental_reindexes"); + ASSERT_STR_EQ(entry->default_val, CBM_RANK_REFRESH_DEFAULT); + ASSERT_STR_EQ(entry->category, "PageRank"); + ASSERT_STR_EQ(entry->range, + "at_publish|defer_exact_delta_reindexes|defer_all_incremental_reindexes"); + ASSERT_NOT_NULL(strstr(entry->guidance, "small exact-delta reindexes")); + ASSERT_NOT_NULL(strstr(entry->guidance, "dependency reindexes")); PASS(); } -TEST(pipeline_double_free_prevention) { - /* free(NULL) after free should not crash. We can't truly double-free - * the same pointer, but we verify NULL is safe as documented. */ - cbm_pipeline_free(NULL); - cbm_pipeline_free(NULL); +TEST(config_registry_includes_capability_gates) { + const char *keys[] = {CBM_CONFIG_RANK_ENABLED, CBM_CONFIG_SIMILARITY_ENABLED, + CBM_CONFIG_SEMANTIC_EDGES_ENABLED, CBM_CONFIG_GITHISTORY_ENABLED, + CBM_CONFIG_HTTPLINKS_ENABLED}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + const cbm_config_entry_t *entry = find_config_entry(keys[i]); + ASSERT_NOT_NULL(entry); + ASSERT_STR_EQ(entry->default_val, "true"); + ASSERT_STR_EQ(entry->range, "true|false"); + ASSERT_NOT_NULL(strstr(entry->guidance, "config set")); + } PASS(); } @@ -7524,6 +20460,152 @@ TEST(pipeline_complexity_transitive_loop_depth) { PASS(); } +static void loop_props(char *buf, size_t buf_sz, int loop_depth) { + snprintf(buf, buf_sz, "{\"loop_depth\":%d,\"self_recursive\":false}", loop_depth); +} + +TEST(pipeline_complexity_scc_tld_is_deterministic) { + enum { + CX_LOOP_A = 1, + CX_LOOP_B = 2, + CX_LOOP_LEAF = 3, + CX_COMPONENT_TLD = CX_LOOP_B + CX_LOOP_LEAF, + }; + cbm_gbuf_t *gb = cbm_gbuf_new("cx-scc", "/tmp/cx-scc"); + ASSERT_NOT_NULL(gb); + + char props_a[CBM_SZ_64]; + char props_b[CBM_SZ_64]; + char props_leaf[CBM_SZ_64]; + loop_props(props_a, sizeof(props_a), CX_LOOP_A); + loop_props(props_b, sizeof(props_b), CX_LOOP_B); + loop_props(props_leaf, sizeof(props_leaf), CX_LOOP_LEAF); + + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "cx.a", "cx.go", 1, 4, props_a); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "b", "cx.b", "cx.go", 5, 8, props_b); + int64_t leaf = + cbm_gbuf_upsert_node(gb, "Function", "leaf", "cx.leaf", "cx.go", 9, 12, props_leaf); + ASSERT_GT(a, 0); + ASSERT_GT(b, 0); + ASSERT_GT(leaf, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, a, b, "CALLS", "{}"), 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, b, a, "CALLS", "{}"), 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, b, leaf, "CALLS", "{}"), 0); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "cx-scc", + .repo_path = "/tmp/cx-scc", + .gbuf = gb, + .cancelled = &cancelled, + }; + cbm_pipeline_pass_complexity(&ctx); + + const cbm_gbuf_node_t *node_a = cbm_gbuf_find_by_qn(gb, "cx.a"); + const cbm_gbuf_node_t *node_b = cbm_gbuf_find_by_qn(gb, "cx.b"); + ASSERT_NOT_NULL(node_a); + ASSERT_NOT_NULL(node_b); + ASSERT_NOT_NULL(node_a->properties_json); + ASSERT_NOT_NULL(node_b->properties_json); + + char expected_tld[CBM_SZ_64]; + snprintf(expected_tld, sizeof(expected_tld), "\"transitive_loop_depth\":%d", + CX_COMPONENT_TLD); + ASSERT_NOT_NULL(strstr(node_a->properties_json, expected_tld)); + ASSERT_NOT_NULL(strstr(node_b->properties_json, expected_tld)); + ASSERT_NOT_NULL(strstr(node_a->properties_json, "\"recursive\":true")); + ASSERT_NOT_NULL(strstr(node_b->properties_json, "\"recursive\":true")); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_complexity_scoped_writeback_keeps_unchanged_nodes) { + cbm_gbuf_t *gb = cbm_gbuf_new("cx-scope", "/tmp/cx-scope"); + ASSERT_NOT_NULL(gb); + + char changed_props[CBM_SZ_64]; + char unchanged_props[CBM_SZ_128]; + loop_props(changed_props, sizeof(changed_props), 1); + snprintf(unchanged_props, sizeof(unchanged_props), + "{\"loop_depth\":1,\"transitive_loop_depth\":3,\"self_recursive\":false," + "\"stable\":true}"); + + int64_t changed = + cbm_gbuf_upsert_node(gb, "Function", "changed", "cx.changed", "changed.go", 1, 4, + changed_props); + int64_t unchanged = + cbm_gbuf_upsert_node(gb, "Function", "unchanged", "cx.unchanged", "unchanged.go", 1, 4, + unchanged_props); + ASSERT_GT(changed, 0); + ASSERT_GT(unchanged, 0); + ASSERT_GT(cbm_gbuf_insert_edge(gb, changed, unchanged, "CALLS", "{}"), 0); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "cx-scope", + .repo_path = "/tmp/cx-scope", + .gbuf = gb, + .cancelled = &cancelled, + }; + const char *scope[] = {"changed.go"}; + cbm_pipeline_pass_complexity_for_paths(&ctx, scope, (int)(sizeof(scope) / sizeof(scope[0]))); + + const cbm_gbuf_node_t *changed_node = cbm_gbuf_find_by_qn(gb, "cx.changed"); + const cbm_gbuf_node_t *unchanged_node = cbm_gbuf_find_by_qn(gb, "cx.unchanged"); + ASSERT_NOT_NULL(changed_node); + ASSERT_NOT_NULL(unchanged_node); + ASSERT_NOT_NULL(changed_node->properties_json); + ASSERT_NOT_NULL(unchanged_node->properties_json); + ASSERT_NOT_NULL(strstr(changed_node->properties_json, "\"transitive_loop_depth\":4")); + ASSERT_NOT_NULL(strstr(unchanged_node->properties_json, "\"transitive_loop_depth\":3")); + ASSERT_NOT_NULL(strstr(unchanged_node->properties_json, "\"stable\":true")); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_complexity_scoped_writeback_preserves_stored_recursive) { + cbm_gbuf_t *gb = cbm_gbuf_new("cx-scope-rec", "/tmp/cx-scope-rec"); + ASSERT_NOT_NULL(gb); + + const char *changed_props = + "{\"loop_depth\":1,\"transitive_loop_depth\":3,\"self_recursive\":false," + "\"recursive\":true}"; + int64_t changed = + cbm_gbuf_upsert_node(gb, "Function", "changed", "cx.changed", "changed.go", 1, 4, + changed_props); + int64_t unchanged = + cbm_gbuf_upsert_node(gb, "Function", "unchanged", "cx.unchanged", "unchanged.go", 1, 4, + changed_props); + ASSERT_GT(changed, 0); + ASSERT_GT(unchanged, 0); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "cx-scope-rec", + .repo_path = "/tmp/cx-scope-rec", + .gbuf = gb, + .cancelled = &cancelled, + }; + const char *scope[] = {"changed.go"}; + cbm_pipeline_pass_complexity_for_paths(&ctx, scope, (int)(sizeof(scope) / sizeof(scope[0]))); + + const cbm_gbuf_node_t *changed_node = cbm_gbuf_find_by_qn(gb, "cx.changed"); + const cbm_gbuf_node_t *unchanged_node = cbm_gbuf_find_by_qn(gb, "cx.unchanged"); + ASSERT_NOT_NULL(changed_node); + ASSERT_NOT_NULL(unchanged_node); + ASSERT_NOT_NULL(changed_node->properties_json); + ASSERT_NOT_NULL(unchanged_node->properties_json); + ASSERT_NOT_NULL(strstr(changed_node->properties_json, "\"transitive_loop_depth\":1")); + ASSERT_NOT_NULL(strstr(changed_node->properties_json, "\"recursive\":false")); + ASSERT_NOT_NULL(strstr(unchanged_node->properties_json, "\"transitive_loop_depth\":3")); + ASSERT_NOT_NULL(strstr(unchanged_node->properties_json, "\"recursive\":true")); + + cbm_gbuf_free(gb); + PASS(); +} + /* Regression for #334: the plausibility gate compares committed (extracted) * node count against persisted rows. committed_nodes must be captured BEFORE * cbm_gbuf_dump_to_sqlite frees the gbuf node index — otherwise it reads 0 and @@ -7566,6 +20648,33 @@ TEST(pipeline_committed_counts_match_persisted) { PASS(); } +TEST(pipeline_rejects_overlong_db_path_without_truncated_write) { + if (setup_test_repo() != 0) { + FAIL("failed to create temp dir"); + } + + char db_path[PIPELINE_TEST_OVERLONG_DB_PATH]; + int prefix_len = snprintf(db_path, sizeof(db_path), "%s/", g_tmpdir); + ASSERT_GT(prefix_len, 0); + ASSERT_TRUE((size_t)prefix_len < sizeof(db_path)); + memset(db_path + prefix_len, 'a', sizeof(db_path) - (size_t)prefix_len - CBM_ALLOC_ONE); + db_path[sizeof(db_path) - 1] = '\0'; + + char truncated[CBM_PATH_MAX]; + int trunc_len = snprintf(truncated, sizeof(truncated), "%s", db_path); + ASSERT_TRUE(trunc_len >= CBM_PATH_MAX); + + cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(p); + int rc = cbm_pipeline_run(p); + ASSERT_NEQ(rc, 0); + ASSERT_NEQ(access(truncated, F_OK), 0); + + cbm_pipeline_free(p); + teardown_test_repo(); + PASS(); +} + /* Reproduce-first (perf, linux-kernel finding): the extraction back-pressure * gate must stop re-paying the full collect+nap tax on every file pull once a * full nap cycle has failed to reclaim under budget. With CBM_MEM_BUDGET_MB=1 @@ -7761,6 +20870,83 @@ SUITE(pipeline) { RUN_TEST(pipeline_cancel); RUN_TEST(pipeline_cancel_null); RUN_TEST(pipeline_run_null); + RUN_TEST(pipeline_unit_threshold_setters_clamp_invalid_values); + RUN_TEST(pipeline_githistory_max_couplings_clamps_to_shared_range); + RUN_TEST(pipeline_apply_config_sets_all_thresholds); + RUN_TEST(pipeline_capability_gates_default_enabled); + RUN_TEST(pipeline_disabled_capabilities_skip_expensive_passes); + RUN_TEST(pipeline_githistory_compute_overlaps_independent_postpasses); + RUN_TEST(pipeline_capability_combinations_have_unique_fingerprints); + RUN_TEST(pipeline_exact_delta_limits_keep_safe_defaults); + RUN_TEST(pipeline_semantic_edges_independent_of_call_insertion_order); + RUN_TEST(pipeline_semantic_corpus_vectors_independent_of_worker_count); + RUN_TEST(pipeline_semantic_corpus_add_doc_reserves_without_losing_docs); + RUN_TEST(pipeline_semantic_batch_rejects_invalid_token_stride); + RUN_TEST(pipeline_semantic_corpus_accepts_nonuniform_docs_beyond_legacy_stride); + RUN_TEST(pipeline_semantic_batch_rejects_nonempty_corpus_without_reordering_existing_ids); + RUN_TEST(pipeline_semantic_edges_tokenize_complete_long_metadata); + RUN_TEST(pipeline_semantic_edges_tokenize_escaped_json_metadata); + RUN_TEST(pipeline_semantic_edges_reports_noisy_bucket_partial_results); + RUN_TEST(pipeline_semantic_candidate_rank_prefers_band_evidence_canonically); + RUN_TEST(config_registry_includes_mcp_timeout_knobs); + RUN_TEST(config_registry_includes_incremental_reindex_policy); + RUN_TEST(config_registry_includes_extract_timeout); + RUN_TEST(config_registry_includes_overlay_publish_policy); + RUN_TEST(config_registry_includes_overlay_compaction_policy); + RUN_TEST(config_registry_includes_incremental_exact_frontier_caps); + RUN_TEST(config_registry_includes_incremental_derived_results_refresh_policy); + RUN_TEST(config_registry_includes_githistory_max_couplings); + RUN_TEST(config_registry_includes_rank_refresh_policy); + RUN_TEST(config_registry_includes_capability_gates); + RUN_TEST(pipeline_file_delta_scratch_seed_excludes_changed_paths); + RUN_TEST(pipeline_file_delta_scratch_seed_preserves_structure_roots); + RUN_TEST(pipeline_file_delta_scratch_seed_supports_external_endpoint_descriptor); + RUN_TEST(pipeline_file_delta_descriptor_from_gbuf); + RUN_TEST(pipeline_file_delta_detects_cross_file_node_qn_collision); + RUN_TEST(pipeline_file_delta_owns_target_header_usage_edges); + RUN_TEST(pipeline_file_delta_preserves_safe_inbound_edges_for_overlay); + RUN_TEST(pipeline_file_delta_preserves_sibling_named_imports_to_shared_target); + RUN_TEST(pipeline_file_delta_descriptor_marks_unsupported_edges); + RUN_TEST(pipeline_file_delta_metadata_from_file); + RUN_TEST(pipeline_file_delta_metadata_accepts_effective_fingerprint); + RUN_TEST(pipeline_file_delta_stamp_generation_updates_metadata); + RUN_TEST(pipeline_content_hash_helper_matches_file_delta_metadata); + RUN_TEST(pipeline_file_state_persist_helper_writes_hash_metadata); + RUN_TEST(pipeline_file_state_current_check_rejects_stale_pass_fingerprint); + RUN_TEST(pipeline_pass_fingerprint_includes_effective_mode_and_thresholds); + RUN_TEST(pipeline_file_state_current_check_rejects_stale_config_fingerprint); + RUN_TEST(pipeline_file_state_persist_helper_rolls_back_on_failure); + RUN_TEST(pipeline_file_delta_plan_candidate_from_frontier); + RUN_TEST(pipeline_file_delta_apply_falls_back_on_publish_error); + RUN_TEST(pipeline_file_delta_apply_falls_back_without_generation); + RUN_TEST(pipeline_file_delta_apply_succeeds_after_generation_stamp); + RUN_TEST(pipeline_file_delta_apply_inserts_new_file_without_existing_ownership); + RUN_TEST(pipeline_file_delta_apply_falls_back_on_new_file_importer_frontier); + RUN_TEST(pipeline_file_delta_plan_falls_back_without_existing_ownership); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_external_inbound_edge); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_unowned_structural_inbound_edge); + RUN_TEST(pipeline_file_delta_plan_accepts_full_pipeline_structure_edge); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_new_folder_structure_edge); + RUN_TEST(pipeline_file_delta_apply_inserts_and_prunes_new_folder_context); + RUN_TEST(pipeline_file_delta_plan_accepts_regenerated_structural_inbound_edge); + RUN_TEST(pipeline_file_delta_plan_accepts_regenerated_file_owned_unowned_source_edge); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_stale_file_owned_unowned_source_edge); + RUN_TEST(pipeline_file_delta_plan_falls_back_without_file_metadata); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_unsupported_edges); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_delete); + RUN_TEST(pipeline_file_delta_apply_deletes_owned_file_delta); + RUN_TEST(pipeline_file_delta_apply_mixed_delete_upsert_batch); + RUN_TEST(pipeline_file_delta_apply_falls_back_on_delete_batch); + RUN_TEST(pipeline_file_delta_apply_falls_back_when_frontier_path_missing_from_batch); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_rename); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_unsupported_derived_view); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_unresolved_edge_endpoint); + RUN_TEST(pipeline_file_delta_plan_accepts_resolved_external_edge_endpoint); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_large_frontier); + RUN_TEST(pipeline_file_delta_plan_frontier_noop_mask_bounds_recursive_frontier); + RUN_TEST(pipeline_file_delta_plan_frontier_noop_mask_skips_masked_inbound_precheck); + RUN_TEST(pipeline_file_delta_plan_batch_accepts_mutual_frontier); + RUN_TEST(pipeline_file_delta_orchestrates_descriptor_plan_and_publish); /* Extraction back-pressure */ RUN_TEST(pipeline_backpressure_futile_nap_disengages); /* Sequential cross-LSP shared registry (ms-typescript quadratic) */ @@ -7770,11 +20956,18 @@ SUITE(pipeline) { RUN_TEST(store_bulk_persistence); /* Integration: structure pass */ RUN_TEST(pipeline_structure_nodes); + RUN_TEST(pipeline_full_reindex_preserves_adr_and_sibling_project); RUN_TEST(pipeline_committed_counts_match_persisted); + RUN_TEST(pipeline_rejects_overlong_db_path_without_truncated_write); RUN_TEST(pipeline_adr_survives_full_reindex); RUN_TEST(pipeline_structure_edges); RUN_TEST(pipeline_branch_root_structure); RUN_TEST(pipeline_project_name_derived); + RUN_TEST(pipeline_mode_global_semantic_edges_policy); + RUN_TEST(pipeline_call_edge_props_include_args_and_line); + RUN_TEST(pipeline_weak_call_target_suppression); + RUN_TEST(pipeline_member_call_normalization); + RUN_TEST(pipeline_sequential_call_edges_preserve_eighth_arg); RUN_TEST(pipeline_fast_mode); /* Definitions pass */ RUN_TEST(pipeline_definitions_function_nodes); @@ -7782,13 +20975,26 @@ SUITE(pipeline) { RUN_TEST(pipeline_definitions_properties); RUN_TEST(pipeline_def_props_valid_json_when_oversized); RUN_TEST(pipeline_edge_props_valid_json); + RUN_TEST(pipeline_persisted_route_purity_for_http_literals); + RUN_TEST(pipeline_infra_route_deny_wins_by_url_value); /* Complexity propagation pass (Tier B) */ RUN_TEST(pipeline_complexity_transitive_loop_depth); + RUN_TEST(pipeline_complexity_scc_tld_is_deterministic); + RUN_TEST(pipeline_complexity_scoped_writeback_keeps_unchanged_nodes); + RUN_TEST(pipeline_complexity_scoped_writeback_preserves_stored_recursive); /* Calls pass */ RUN_TEST(pipeline_calls_resolution); RUN_TEST(pipeline_nix_scoped_binding_calls_resolve); RUN_TEST(pipeline_incremental_preserves_cross_file_calls); + RUN_TEST(pipeline_full_and_incremental_persist_file_state); + RUN_TEST(pipeline_incremental_full_index_rebuilds_owner_metadata); RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_rust_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_rust_receiver_parallel_suppresses_weak_method_edge); + RUN_TEST(pipeline_full_url_call_joins_canonical_route); + RUN_TEST(pipeline_route_discovery_uses_canonical_identities_sequential); + RUN_TEST(pipeline_route_discovery_uses_canonical_identities_parallel); + RUN_TEST(pipeline_httplink_collection_has_no_fixed_item_ceiling); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); @@ -7797,6 +21003,9 @@ SUITE(pipeline) { RUN_TEST(githistory_is_trackable); RUN_TEST(githistory_compute_coupling); RUN_TEST(githistory_coupling_carries_last_co_change); + RUN_TEST(githistory_coupling_ranks_bounded_output_and_reports_omissions); + RUN_TEST(githistory_temporal_retains_files_past_legacy_capacity); + RUN_TEST(githistory_temporal_preserves_long_file_paths); RUN_TEST(githistory_skip_large_commits); RUN_TEST(githistory_limits_to_max); /* Test detection */ @@ -7804,6 +21013,7 @@ SUITE(pipeline) { RUN_TEST(testdetect_is_test_function); /* Implements pass (graph buffer based) */ RUN_TEST(implements_creates_override); + RUN_TEST(implements_accepts_struct_label); RUN_TEST(implements_no_match); /* Usages pass (full pipeline integration) */ RUN_TEST(usages_creates_edges); @@ -7814,9 +21024,16 @@ SUITE(pipeline) { /* Language integration tests */ RUN_TEST(pipeline_python_project); RUN_TEST(pipeline_imports_multi_symbol_edges); + RUN_TEST(pipeline_typescript_barrel_reexport_call_resolves_implementation); + RUN_TEST(pipeline_python_pyo3_import_resolves_rust_function_calls); RUN_TEST(pipeline_go_cross_package_call); RUN_TEST(pipeline_swift_cross_package_import); RUN_TEST(pipeline_python_cross_module_call); + RUN_TEST(pipeline_python_reexport_call_uses_resolved_import_edge); + RUN_TEST(pipeline_incremental_reexport_target_matches_full); + RUN_TEST(pipeline_parallel_duplicate_import_inherits_matches_sequential); + RUN_TEST(pipeline_parallel_env_access_matches_sequential); + RUN_TEST(pipeline_parallel_channel_edges_target_channels); RUN_TEST(pipeline_go_type_classification); RUN_TEST(pipeline_go_grouped_types); RUN_TEST(pipeline_kotlin_project); @@ -7830,7 +21047,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_docstring_python_function); RUN_TEST(pipeline_docstring_java_method); RUN_TEST(pipeline_docstring_kotlin_function); - RUN_TEST(pipeline_docstring_go_class); + RUN_TEST(pipeline_docstring_go_struct); /* Project name */ RUN_TEST(project_name_from_path); RUN_TEST(project_name_drive_letter_case_insensitive_issue394); @@ -7879,6 +21096,13 @@ SUITE(pipeline) { RUN_TEST(infra_is_k8s_manifest); RUN_TEST(infra_is_env_file); RUN_TEST(infra_clean_json_brackets); + /* K8s extraction tests */ + RUN_TEST(k8s_extract_kustomize); + RUN_TEST(k8s_extract_manifest); + RUN_TEST(k8s_extract_manifest_no_name); + RUN_TEST(k8s_extract_manifest_multidoc); + RUN_TEST(k8s_selector_links_manifests_after_former_record_limit); + RUN_TEST(k8s_selector_requires_every_key_value_pair_beyond_former_pair_limit); RUN_TEST(infra_secret_detection); /* Infrascan: Dockerfile parser */ RUN_TEST(infra_parse_dockerfile_multistage); @@ -7907,11 +21131,6 @@ SUITE(pipeline) { /* Infrascan: pipeline integration */ RUN_TEST(infra_pipeline_integration); RUN_TEST(infra_pipeline_idempotent); - /* K8s / Kustomize extraction */ - RUN_TEST(k8s_extract_kustomize); - RUN_TEST(k8s_extract_manifest); - RUN_TEST(k8s_extract_manifest_no_name); - RUN_TEST(k8s_extract_manifest_multidoc); /* Env URL scanning */ RUN_TEST(envscan_dockerfile_env_urls); RUN_TEST(envscan_shell_env_urls); @@ -7924,9 +21143,19 @@ SUITE(pipeline) { RUN_TEST(envscan_secret_value_exclusion); RUN_TEST(envscan_secret_file_exclusion); RUN_TEST(envscan_skips_ignored_dirs); + RUN_TEST(envscan_does_not_follow_links_outside_root); RUN_TEST(envscan_non_url_values_skipped); + RUN_TEST(envscan_walks_more_than_256_pending_directories); + RUN_TEST(envscan_accepts_root_path_longer_than_512_bytes); + RUN_TEST(envscan_uses_shared_file_size_policy); + RUN_TEST(envscan_parses_one_complete_line_across_old_buffer_boundary); + RUN_TEST(envscan_concurrent_first_use_and_cleanup_reinitialize); + RUN_TEST(envscan_reports_unrepresentable_key_and_value_without_truncating); + RUN_TEST(envscan_preserves_caller_output_capacity); /* SwiftPM Package.swift manifest resolution (issue #551 item 1) */ RUN_TEST(pkgmap_swift_targets_registers_module); + RUN_TEST(pkgmap_swift_executable_target_registers_module); + RUN_TEST(pkgmap_swift_literal_path_is_not_silently_truncated); RUN_TEST(pkgmap_swift_products_do_not_register_alias); RUN_TEST(pkgmap_swift_target_name_immediately_before_close_paren); RUN_TEST(pkgmap_swift_target_honors_literal_path); @@ -7936,9 +21165,19 @@ SUITE(pipeline) { RUN_TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry); RUN_TEST(pkgmap_swift_ambiguous_target_name_fails_closed); RUN_TEST(pkgmap_swift_scan_repo_finds_nested_manifest); + RUN_TEST(pkgmap_package_json_entry_is_not_silently_truncated); + RUN_TEST(pkgmap_walk_path_join_is_not_silently_truncated); + RUN_TEST(pkgmap_walk_reaches_manifest_beyond_legacy_depth_cap); + RUN_TEST(pkgmap_walk_does_not_follow_directory_symlink_cycle); + RUN_TEST(pkgmap_manifest_ecosystems_preserve_long_entry_paths); + RUN_TEST(pkgmap_pom_coordinates_are_not_silently_truncated); + RUN_TEST(pkgmap_manifest_above_legacy_cap_uses_shared_file_limit); /* Discovery-exclusion plumbing in auxiliary repo walks (#792) */ RUN_TEST(pipeline_relpath_excluded_boundary); RUN_TEST(pkgmap_scan_repo_honors_discovery_exclusions); + RUN_TEST(pkgmap_prefix_slash_result_is_not_silently_truncated); + RUN_TEST(pkgmap_prefix_dot_result_is_not_silently_truncated); + RUN_TEST(pkgmap_prefix_backslash_result_is_not_silently_truncated); RUN_TEST(envscan_walk_honors_discovery_exclusions); /* Function registry / resolver */ RUN_TEST(registry_resolve_single_candidate); @@ -7952,6 +21191,13 @@ SUITE(pipeline) { RUN_TEST(registry_confidence_same_module); RUN_TEST(registry_confidence_unique_name); RUN_TEST(registry_confidence_suffix_match); + RUN_TEST(pipeline_python_super_init_external_lsp_suppresses_suffix_fallback); + RUN_TEST(pipeline_python_super_init_without_lsp_suppresses_weak_suffix_fallback); + RUN_TEST(pipeline_external_lsp_target_suppresses_suffix_fallback); + RUN_TEST(pipeline_internal_lsp_declaration_keeps_canonical_registry_fallback); + RUN_TEST(pipeline_python_file_self_call_suppresses_weak_suffix_fallback); + RUN_TEST(pipeline_python_file_dotted_call_suppresses_weak_suffix_fallback); + RUN_TEST(pipeline_python_file_dotted_call_keeps_import_reachable_suffix_fallback); RUN_TEST(registry_fuzzy_confidence_single); RUN_TEST(registry_fuzzy_confidence_distance); RUN_TEST(registry_negative_import_rejects); @@ -7967,12 +21213,85 @@ SUITE(pipeline) { RUN_TEST(githistory_coupling_skips_large_commits); RUN_TEST(githistory_coupling_limits_output); /* Incremental reindex */ - /* FastAPI Depends edge tracking (PR #66 port) */ + /* FastAPI Depends edge tracking */ RUN_TEST(pipeline_fastapi_depends_edges); + RUN_TEST(import_edge_helper_escapes_local_name_once); + RUN_TEST(import_edge_helper_preserves_long_local_name); + RUN_TEST(import_map_from_edges_follows_package_reexport); + RUN_TEST(import_reexport_falls_back_when_pkgmap_target_missing); + RUN_TEST(import_symbol_fallback_prefers_import_path_over_insertion_order); + RUN_TEST(import_resolution_long_header_prefers_source_relative_file); + RUN_TEST(import_resolution_long_sibling_path_is_exact); + RUN_TEST(import_resolution_long_namespace_key_and_qn_are_exact); + RUN_TEST(import_resolution_namespace_map_normalizes_declaration_and_import); + RUN_TEST(import_resolution_symbol_fallback_has_no_segment_limit); + RUN_TEST(import_resolution_long_symbol_name_is_exact); /* Incremental */ RUN_TEST(incremental_full_then_noop); - RUN_TEST(incremental_detects_changed_file); RUN_TEST(incremental_aborts_when_previous_coverage_is_unreadable); + RUN_TEST(incremental_touch_only_refreshes_metadata_without_reindex); + RUN_TEST(incremental_detects_changed_file); + RUN_TEST(incremental_fast_exact_upsert_matches_full_rebuild); + RUN_TEST(incremental_fast_body_only_change_uses_graph_noop); + RUN_TEST(incremental_fast_two_file_batch_exact_upsert_matches_full_rebuild); + RUN_TEST( + incremental_fast_configured_cap_uses_containment_for_oversized_inbound_frontier); + RUN_TEST(incremental_fast_c_header_frontier_too_large_uses_full_rebuild); + RUN_TEST(incremental_fast_default_c_header_frontier_cap_allows_bounded_exact); + RUN_TEST(incremental_fast_c_source_frontier_too_large_uses_full_rebuild); + RUN_TEST(incremental_fast_default_c_source_frontier_cap_allows_bounded_exact); + RUN_TEST(incremental_overlay_publish_single_c_header_uses_active_overlay); + RUN_TEST(incremental_overlay_single_c_header_type_impl_pair_keeps_canonical_rows_visible); + RUN_TEST(incremental_c_header_batch_uses_additive_overlay_when_owned_rows_preserved); + RUN_TEST(incremental_c_header_uses_exact_not_additive_overlay_without_subset_proof); + RUN_TEST(incremental_fast_configured_frontier_cap_allows_bounded_exact); + RUN_TEST(incremental_full_defer_exact_delta_reindexes_defers_global_derived_refresh); + RUN_TEST(incremental_full_defer_exact_delta_reindexes_mixed_delete_upsert_marks_semantic_stale); + RUN_TEST(incremental_full_defer_all_incremental_reindexes_defers_containment_semantic_refresh); + RUN_TEST(incremental_fast_mixed_unowned_edge_frontier_falls_back_to_full_rebuild); + RUN_TEST(incremental_fast_expands_small_inbound_frontier_and_matches_full); + RUN_TEST(incremental_fast_three_file_batch_falls_back_to_full_rebuild_parity); + RUN_TEST(incremental_fast_single_delete_exact_matches_full_rebuild); + RUN_TEST(incremental_fast_delete_falls_back_to_full_rebuild_parity); + RUN_TEST(incremental_fast_rename_like_batch_falls_back_to_full_rebuild_parity); + RUN_TEST(incremental_fast_new_folder_exact_delta_parity); + RUN_TEST(incremental_fast_route_decorator_change_matches_fresh_rebuild); + RUN_TEST(incremental_fast_arg_url_route_change_matches_parallel_full_rebuild); + RUN_TEST(incremental_fast_exact_scratch_multifile_usage_edges_match_fresh); + RUN_TEST(incremental_fast_exact_batch_publish_matches_fresh_rebuild_for_two_file_go); + RUN_TEST(incremental_overlay_producer_marks_dirty_ready_without_canonical_mutation); + RUN_TEST(incremental_overlay_publish_small_deltas_keeps_canonical_base_visible); + RUN_TEST(incremental_exact_python_scoped_lsp_gap_matches_full_rebuild); + RUN_TEST(incremental_javascript_scoped_lsp_gap_reports_full_rebuild_not_cap_overflow); + RUN_TEST(incremental_cross_lsp_language_matrix_matches_fresh_rebuild); + RUN_TEST(incremental_objectscript_unchanged_include_macro_matches_fresh_rebuild); + RUN_TEST(incremental_objectscript_changed_include_reextracts_consumers); + RUN_TEST(incremental_objectscript_deleted_include_reextracts_consumers); + RUN_TEST(incremental_mixed_python_rust_edits_match_fresh_rebuild); + RUN_TEST(incremental_mixed_rust_typescript_javascript_matches_fresh_rebuild); + RUN_TEST(incremental_exact_python_receiver_type_gap_matches_full_rebuild); + RUN_TEST(pipeline_persisted_python_defs_feed_scoped_cross_lsp); + RUN_TEST(pipeline_store_backed_lsp_cross_uses_import_scope_defs); + RUN_TEST(incremental_exact_scratch_store_backed_lsp_matches_fresh_rebuild); + RUN_TEST(incremental_exact_scratch_field_hint_materializes_store_target); + RUN_TEST(incremental_exact_scratch_python_package_matches_fresh_rebuild); + RUN_TEST(incremental_overlay_first_preserves_inbound_edges_past_exact_frontier_cap); + RUN_TEST(incremental_overlay_publish_delete_keeps_canonical_base_visible); + RUN_TEST(incremental_overlay_publish_repeated_update_keeps_active_view_idempotent); + RUN_TEST(incremental_overlay_publish_failure_falls_back_to_canonical_exact); + RUN_TEST(incremental_overlay_extract_failure_keeps_dirty_pending_without_overlay); + RUN_TEST(incremental_full_mode_keeps_exact_upsert_disabled); + RUN_TEST(incremental_detects_same_size_rewrite_with_preserved_mtime); + RUN_TEST(incremental_missing_file_state_keeps_legacy_metadata_path); + RUN_TEST(incremental_publish_failure_keeps_existing_db); + RUN_TEST(incremental_frontier_full_fallback_failure_preserves_dirty_ledger); + RUN_TEST(incremental_postpass_failure_keeps_existing_db); + RUN_TEST(incremental_hash_persist_failure_falls_back_to_full); + RUN_TEST(incremental_parallel_extract_failure_keeps_existing_db); + RUN_TEST(incremental_parallel_success_releases_package_map); + RUN_TEST(incremental_parallel_registry_failure_keeps_existing_db); + RUN_TEST(incremental_parallel_resolve_failure_keeps_existing_db); + RUN_TEST(incremental_classify_deleted_failure_keeps_existing_db); RUN_TEST(incremental_detects_deleted_file); RUN_TEST(incremental_new_file_added); RUN_TEST(cancelled_full_reindex_preserves_committed_db); @@ -7988,6 +21307,7 @@ SUITE(pipeline) { /* Resource management & internal helper tests */ RUN_TEST(pipeline_empty_path); RUN_TEST(pipeline_project_name_content); + RUN_TEST(pipeline_publish_kind_names_are_stable); RUN_TEST(pipeline_cancel_sets_flag); RUN_TEST(pipeline_double_cancel); RUN_TEST(pipeline_double_free_prevention); @@ -8044,4 +21364,7 @@ SUITE(pipeline) { /* Project name edge cases */ RUN_TEST(project_name_special_chars); RUN_TEST(project_name_trailing_slash); + /* Release pipeline-level global state (compiled regex patterns etc.). + * Patterns are compiled on first use and cached; free once at suite end. */ + cbm_pipeline_global_cleanup(); } diff --git a/tests/test_platform.c b/tests/test_platform.c index d705c672f..dffd5eea4 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -9,6 +9,7 @@ #include "../src/foundation/platform.h" #include "../src/foundation/platform_internal.h" #include "../src/foundation/system_info_internal.h" +#include #include #include #include @@ -93,10 +94,48 @@ TEST(platform_mkstemp_and_mkdtemp_survive_non_ascii_directory) { #else close(descriptor); #endif + } + + static const char probe[] = "probe"; + FILE *probe_file = created ? cbm_fopen(file_template, "wb") : NULL; + bool probe_written = false; + if (probe_file) { + bool write_ok = + fwrite(probe, sizeof(probe) - SKIP_ONE, SKIP_ONE, probe_file) == SKIP_ONE; + bool close_ok = fclose(probe_file) == 0; + probe_written = write_ok && close_ok; + } + struct stat directory_state = {0}; + struct stat file_state = {0}; + int directory_stat = cbm_stat(base, &directory_state); + int file_stat = cbm_stat(file_template, &file_state); + cbm_file_identity_t directory_identity = {0}; + cbm_file_identity_t file_identity = {0}; + cbm_file_identity_t repeated_file_identity = {0}; + bool directory_identity_read = cbm_file_identity_read(base, &directory_identity); + bool file_identity_read = cbm_file_identity_read(file_template, &file_identity); + bool repeated_file_identity_read = + cbm_file_identity_read(file_template, &repeated_file_identity); + if (created) { (void)cbm_unlink(file_template); } + errno = 0; + int missing_stat = cbm_stat(file_template, &file_state); + int missing_error = errno; (void)cbm_rmdir(base); ASSERT_TRUE(created); + ASSERT_TRUE(probe_written); + ASSERT_EQ(directory_stat, 0); + ASSERT_TRUE(S_ISDIR(directory_state.st_mode)); + ASSERT_EQ(file_stat, 0); + ASSERT_TRUE(S_ISREG(file_state.st_mode)); + ASSERT_EQ(file_state.st_size, (off_t)(sizeof(probe) - SKIP_ONE)); + ASSERT_TRUE(directory_identity_read); + ASSERT_TRUE(file_identity_read); + ASSERT_TRUE(repeated_file_identity_read); + ASSERT_TRUE(cbm_file_identity_equal(&file_identity, &repeated_file_identity)); + ASSERT_EQ(missing_stat, -1); + ASSERT_EQ(missing_error, ENOENT); /* The returned path must keep the caller's UTF-8 directory intact. */ ASSERT_NOT_NULL(strstr(file_template, "éè")); PASS(); @@ -125,6 +164,42 @@ TEST(platform_counter_scaling_preserves_monotonic_deadlines) { PASS(); } +TEST(platform_proc_stat_group_parser_handles_parentheses_and_states) { + int64_t process_group = 0; + bool execution_quiescent = false; + ASSERT_TRUE(cbm_platform_parse_proc_stat_group( + "123 (ordinary worker) R 7 41 41 0 -1 0", &process_group, &execution_quiescent)); + ASSERT_EQ(process_group, 41); + ASSERT_FALSE(execution_quiescent); + + ASSERT_TRUE(cbm_platform_parse_proc_stat_group( + "456 (worker ) name with spaces) Z 8 99 99 0 -1 0", &process_group, + &execution_quiescent)); + ASSERT_EQ(process_group, 99); + ASSERT_TRUE(execution_quiescent); + + ASSERT_TRUE(cbm_platform_parse_proc_stat_group( + "789 (dead worker) X 9 101 101 0 -1 0", &process_group, &execution_quiescent)); + ASSERT_EQ(process_group, 101); + ASSERT_TRUE(execution_quiescent); + + ASSERT_FALSE(cbm_platform_parse_proc_stat_group( + "123 missing-close R 7 41", &process_group, &execution_quiescent)); + ASSERT_FALSE(cbm_platform_parse_proc_stat_group( + "123 (missing fields) R 7", &process_group, &execution_quiescent)); + ASSERT_FALSE(cbm_platform_parse_proc_stat_group(NULL, &process_group, &execution_quiescent)); + PASS(); +} + +TEST(platform_proc_entry_disappearance_is_narrow) { + ASSERT_TRUE(cbm_platform_proc_entry_vanished(ENOENT)); + ASSERT_TRUE(cbm_platform_proc_entry_vanished(ESRCH)); + ASSERT_FALSE(cbm_platform_proc_entry_vanished(0)); + ASSERT_FALSE(cbm_platform_proc_entry_vanished(EACCES)); + ASSERT_FALSE(cbm_platform_proc_entry_vanished(EIO)); + PASS(); +} + typedef struct { atomic_int *ready; atomic_bool *go; @@ -191,6 +266,37 @@ TEST(platform_now_ms) { PASS(); } +TEST(platform_thread_condition_uses_monotonic_deadline) { + enum { + CONDITION_TEST_TIMEOUT_MS = 20, + /* A deadline can be observed late under load, but an incorrect clock + * domain must not park the suite indefinitely. */ + CONDITION_TEST_HANG_BOUND_MS = 5000, + }; + cbm_mutex_t mutex; + cbm_thread_condition_t condition; + cbm_mutex_init(&mutex); + int init_status = cbm_thread_condition_init(&condition); + cbm_mutex_lock(&mutex); + uint64_t started_ms = cbm_now_ms(); + cbm_thread_condition_wait_status_t wait_status = + init_status == 0 ? cbm_thread_condition_wait_until(&condition, &mutex, + started_ms + CONDITION_TEST_TIMEOUT_MS) + : CBM_THREAD_CONDITION_WAIT_ERROR; + uint64_t elapsed_ms = cbm_now_ms() - started_ms; + cbm_mutex_unlock(&mutex); + if (init_status == 0) { + cbm_thread_condition_destroy(&condition); + } + cbm_mutex_destroy(&mutex); + + ASSERT_EQ(init_status, 0); + ASSERT_EQ(wait_status, CBM_THREAD_CONDITION_WAIT_TIMEOUT); + ASSERT_TRUE(elapsed_ms >= CONDITION_TEST_TIMEOUT_MS); + ASSERT_TRUE(elapsed_ms <= CONDITION_TEST_HANG_BOUND_MS); + PASS(); +} + TEST(platform_nprocs) { int n = cbm_nprocs(); ASSERT_GT(n, 0); @@ -364,6 +470,37 @@ TEST(platform_setenv_preserves_utf8_in_wide_environment) { PASS(); } +/* cbm_getenv_fits must share cbm_safe_getenv's UTF-16 environment reader. + * SetEnvironmentVariableW deliberately bypasses the CRT's narrow _environ + * snapshot so this test fails if the two public APIs drift apart again. */ +TEST(platform_getenv_fits_reads_windows_wide_environment) { + static const wchar_t name[] = L"CBM_TEST_GETENV_FITS_WIDE"; + static const wchar_t wide[] = L"C:/cbm-config-\u0394-\u65e5\u672c"; + static const char utf8[] = "C:/cbm-config-\xce\x94-\xe6\x97\xa5\xe6\x9c\xac"; + ASSERT_TRUE(SetEnvironmentVariableW(name, wide) != 0); + + char observed[128]; + bool present = false; + bool fits = + cbm_getenv_fits("CBM_TEST_GETENV_FITS_WIDE", observed, sizeof(observed), &present); + bool full_value_present = present; + + char too_small[8] = "stale"; + present = false; + bool too_long = + !cbm_getenv_fits("CBM_TEST_GETENV_FITS_WIDE", too_small, sizeof(too_small), &present); + bool long_value_present = present; + + ASSERT_TRUE(SetEnvironmentVariableW(name, NULL) != 0); + ASSERT_TRUE(fits); + ASSERT_TRUE(full_value_present); + ASSERT_STR_EQ(observed, utf8); + ASSERT_TRUE(too_long); + ASSERT_TRUE(long_value_present); + ASSERT_STR_EQ(too_small, ""); + PASS(); +} + /* Empty and absent variables have different fallback semantics. In * particular, an explicitly empty CBM_CACHE_DIR means "use the default"; it * must not be misreported as a failed wide-environment read. Unset is also @@ -431,10 +568,83 @@ TEST(platform_default_workers_env_unset) { PASS(); } -TEST(platform_system_info) { - cbm_system_info_t info = cbm_system_info(); - ASSERT_GT(info.total_cores, 0); - ASSERT_GT(info.total_ram, 0); +TEST(platform_getenv_fits) { + const char *name = "CBM_TEST_GETENV_FITS"; + char buf[8]; + bool present = true; + + cbm_unsetenv(name); + ASSERT_FALSE(cbm_getenv_fits(name, buf, sizeof(buf), &present)); + ASSERT_FALSE(present); + ASSERT_STR_EQ(buf, ""); + + cbm_setenv(name, "", 1); + present = true; + ASSERT_FALSE(cbm_getenv_fits(name, buf, sizeof(buf), &present)); + ASSERT_FALSE(present); + ASSERT_STR_EQ(buf, ""); + + cbm_setenv(name, "fits", 1); + ASSERT_TRUE(cbm_getenv_fits(name, buf, sizeof(buf), &present)); + ASSERT_TRUE(present); + ASSERT_STR_EQ(buf, "fits"); + + cbm_setenv(name, "too-long-for-buffer", 1); + present = false; + ASSERT_FALSE(cbm_getenv_fits(name, buf, sizeof(buf), &present)); + ASSERT_TRUE(present); + ASSERT_STR_EQ(buf, ""); + + cbm_unsetenv(name); + PASS(); +} + +TEST(platform_env_flag_enabled) { + const char *name = "CBM_TEST_ENV_FLAG"; + + cbm_unsetenv(name); + ASSERT_FALSE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "", 1); + ASSERT_FALSE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "0", 1); + ASSERT_FALSE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "false", 1); + ASSERT_FALSE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "OFF", 1); + ASSERT_FALSE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "No", 1); + ASSERT_FALSE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "1", 1); + ASSERT_TRUE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "true", 1); + ASSERT_TRUE(cbm_env_flag_enabled(name)); + + cbm_setenv(name, "debug", 1); + ASSERT_TRUE(cbm_env_flag_enabled(name)); + + cbm_unsetenv(name); + PASS(); +} + +TEST(platform_dirent_name_fits_boundary) { + char fits[CBM_DIRENT_NAME_MAX]; + char too_long[CBM_DIRENT_NAME_MAX + SKIP_ONE]; + + memset(fits, 'a', sizeof(fits) - SKIP_ONE); + fits[sizeof(fits) - SKIP_ONE] = '\0'; + ASSERT_TRUE(cbm_dirent_name_fits(fits)); + + memset(too_long, 'b', sizeof(too_long) - SKIP_ONE); + too_long[sizeof(too_long) - SKIP_ONE] = '\0'; + ASSERT_FALSE(cbm_dirent_name_fits(too_long)); + ASSERT_FALSE(cbm_dirent_name_fits(NULL)); PASS(); } @@ -610,15 +820,35 @@ TEST(cgroup_no_mem_files) { #endif /* __linux__ */ +/* Restored from the merge base: cbm_system_info() is still live + * (src/foundation/platform.h:107) and load-bearing -- src/daemon/host.c:998 sizes + * the memory pool from total_ram, and src/foundation/mem.c:221 reads it too. It + * lost its only direct test when this file auto-merged, with no conflict raised. + * Strengthened past the base form, which checked only total_cores and total_ram: + * perf_cores is also consumed by callers and must be sane and bounded by + * total_cores. */ +TEST(platform_system_info) { + cbm_system_info_t info = cbm_system_info(); + ASSERT_GT(info.total_cores, 0); + ASSERT_GT(info.total_ram, 0); + ASSERT_GT(info.perf_cores, 0); + ASSERT_TRUE(info.perf_cores <= info.total_cores); + PASS(); +} + SUITE(platform) { RUN_TEST(platform_file_apis_survive_max_path_overflow); RUN_TEST(platform_mkstemp_and_mkdtemp_survive_non_ascii_directory); RUN_TEST(platform_counter_scaling_avoids_intermediate_overflow); RUN_TEST(platform_counter_scaling_preserves_monotonic_deadlines); + RUN_TEST(platform_proc_stat_group_parser_handles_parentheses_and_states); + RUN_TEST(platform_proc_entry_disappearance_is_narrow); RUN_TEST(platform_now_ns_concurrent_first_call); RUN_TEST(platform_now_ns); RUN_TEST(platform_now_ms); + RUN_TEST(platform_thread_condition_uses_monotonic_deadline); RUN_TEST(platform_nprocs); + RUN_TEST(platform_system_info); /* restored from merge base */ RUN_TEST(platform_file_exists); RUN_TEST(platform_is_dir); RUN_TEST(platform_file_size); @@ -628,12 +858,15 @@ SUITE(platform) { RUN_TEST(platform_cache_dir_rejects_truncated_override); #ifdef _WIN32 RUN_TEST(platform_setenv_preserves_utf8_in_wide_environment); + RUN_TEST(platform_getenv_fits_reads_windows_wide_environment); RUN_TEST(platform_windows_empty_environment_is_read_and_unset_idempotently); #endif RUN_TEST(platform_default_workers_env_override); RUN_TEST(platform_default_workers_env_invalid); RUN_TEST(platform_default_workers_env_unset); - RUN_TEST(platform_system_info); + RUN_TEST(platform_getenv_fits); + RUN_TEST(platform_env_flag_enabled); + RUN_TEST(platform_dirent_name_fits_boundary); #ifdef __linux__ RUN_TEST(cgroup_v2_cpu_quota); RUN_TEST(cgroup_v2_cpu_quota_rounds_up); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 818e75f59..5d5c633d8 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -647,6 +647,61 @@ TEST(pylsp_crossfile_classmethod_on_class_issue228) { PASS(); } +TEST(pylsp_crossfile_apirouter_self_method_registry_parity) { + const char *source = + "class APIRouter:\n" + " def add_api_route(self):\n" + " return None\n" + " def include_router(self):\n" + " self.add_api_route()\n"; + + enum { APIROUTER_DEF_COUNT = 3 }; + CBMLSPDef defs[APIROUTER_DEF_COUNT]; + memset(defs, 0, sizeof(defs)); + + defs[0].qualified_name = "fastapi.routing.APIRouter"; + defs[0].short_name = "APIRouter"; + defs[0].label = "Class"; + defs[0].def_module_qn = "fastapi.routing"; + defs[0].lang = CBM_LANG_PYTHON; + + defs[1].qualified_name = "fastapi.routing.APIRouter.add_api_route"; + defs[1].short_name = "add_api_route"; + defs[1].label = "Method"; + defs[1].receiver_type = "fastapi.routing.APIRouter"; + defs[1].def_module_qn = "fastapi.routing"; + defs[1].lang = CBM_LANG_PYTHON; + + defs[2].qualified_name = "fastapi.routing.APIRouter.include_router"; + defs[2].short_name = "include_router"; + defs[2].label = "Method"; + defs[2].receiver_type = "fastapi.routing.APIRouter"; + defs[2].def_module_qn = "fastapi.routing"; + defs[2].lang = CBM_LANG_PYTHON; + + CBMArena direct_arena; + cbm_arena_init(&direct_arena); + CBMResolvedCallArray direct_out = {0}; + cbm_run_py_lsp_cross(&direct_arena, source, (int)strlen(source), "fastapi.routing", defs, + APIROUTER_DEF_COUNT, NULL, NULL, 0, NULL, &direct_out); + ASSERT_GTE(find_resolved_arr(&direct_out, "include_router", "add_api_route"), 0); + + CBMArena registry_arena; + cbm_arena_init(®istry_arena); + CBMTypeRegistry *reg = + cbm_py_build_cross_registry(®istry_arena, defs, APIROUTER_DEF_COUNT); + ASSERT_NOT_NULL(reg); + CBMResolvedCallArray registry_out = {0}; + cbm_run_py_lsp_cross_with_registry(®istry_arena, source, (int)strlen(source), + "fastapi.routing", reg, NULL, NULL, 0, NULL, + ®istry_out); + ASSERT_GTE(find_resolved_arr(®istry_out, "include_router", "add_api_route"), 0); + + cbm_arena_destroy(®istry_arena); + cbm_arena_destroy(&direct_arena); + PASS(); +} + TEST(pylsp_crossfile_inheritance) { /* svc.py defines class Base with shared(); main.py defines class Child(Base) * and calls self.shared(). Caller passes ALL relevant defs (cross-file @@ -1450,6 +1505,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_crossfile_method_dispatch); RUN_TEST(pylsp_fused_self_attr_chain_via_overlay); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); + RUN_TEST(pylsp_crossfile_apirouter_self_method_registry_parity); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_batch_two_files); /* Phase 10 — stdlib resolution */ diff --git a/tests/test_py_lsp_bench.c b/tests/test_py_lsp_bench.c index 9b6bfd41b..989f3d61d 100644 --- a/tests/test_py_lsp_bench.c +++ b/tests/test_py_lsp_bench.c @@ -217,8 +217,13 @@ static double elapsed_ms(struct timespec t0, struct timespec t1) { return s * 1000.0 + ns / 1000000.0; } +enum { + PYLSP_BENCH_NATIVE_MAX_ELAPSED_MS = 150, + PYLSP_BENCH_SANITIZER_MAX_ELAPSED_MS = 1500, +}; + TEST(pylsp_bench_resolution_ratio) { - /* Perf benchmark: time-budgeted. Under ASan+UBSan the budget is scaled up + /* Perf benchmark: time-budgeted. Under sanitizer instrumentation the budget is scaled up * (see the sanitizer-aware budget below) and the result is freed before * asserting so a budget miss doesn't leak. */ int slen = (int)strlen(bench_source); @@ -255,14 +260,12 @@ TEST(pylsp_bench_resolution_ratio) { ASSERT_GTE(resolved * 2, calls); } - /* Time budget. ASan+UBSan instrumentation slows the parse ~5-10×, so - * scale the budget when a sanitizer is active. Native: 150 ms for a - * ~200-line fixture; sanitized: 1500 ms. */ -#if defined(CBM_SANITIZED_BUILD) || defined(__SANITIZE_ADDRESS__) - ASSERT(ms < 1500.0); -#else - ASSERT(ms < 150.0); -#endif + /* Instrumentation changes wall-clock cost without changing the native + * regression ceiling. Keep both budgets explicit and shared sanitizer + * detection portable across GCC and Clang. */ + const double max_elapsed_ms = TF_SANITIZER_ACTIVE ? PYLSP_BENCH_SANITIZER_MAX_ELAPSED_MS + : PYLSP_BENCH_NATIVE_MAX_ELAPSED_MS; + ASSERT(ms < max_elapsed_ms); PASS(); } diff --git a/tests/test_py_lsp_scale.c b/tests/test_py_lsp_scale.c index d9aa5c0da..3f752e818 100644 --- a/tests/test_py_lsp_scale.c +++ b/tests/test_py_lsp_scale.c @@ -8,10 +8,11 @@ #include "lsp/py_lsp.h" #include -static double elapsed_ms(struct timespec t0, struct timespec t1) { - double s = (double)(t1.tv_sec - t0.tv_sec); - double ns = (double)(t1.tv_nsec - t0.tv_nsec); - return s * 1000.0 + ns / 1000000.0; +static double elapsed_cpu_ms(clock_t t0, clock_t t1) { + if (t0 == (clock_t)-1 || t1 == (clock_t)-1 || t1 < t0) { + return -1.0; + } + return (double)(t1 - t0) * 1000.0 / (double)CLOCKS_PER_SEC; } /* Build N synthetic class/call pairs into an arena-backed buffer. */ @@ -51,12 +52,17 @@ static double measure(int n_classes, int *out_calls, int *out_resolved) { int slen = 0; char *src = build_fixture(n_classes, &slen); if (!src) return -1.0; - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); + /* + * This is a complexity guard, so measure process work rather than elapsed + * wall time. A scheduler pause during only the large fixture otherwise + * inflates the ratio and reports a quadratic regression that did not occur. + * ISO C clock() also keeps the measurement portable across supported hosts. + */ + clock_t t0 = clock(); CBMFileResult *r = cbm_extract_file(src, slen, CBM_LANG_PYTHON, "test", "scale.py", 0, NULL, NULL); - clock_gettime(CLOCK_MONOTONIC, &t1); - double ms = elapsed_ms(t0, t1); + clock_t t1 = clock(); + double ms = elapsed_cpu_ms(t0, t1); if (out_calls) *out_calls = r ? r->calls.count : 0; if (out_resolved) *out_resolved = r ? r->resolved_calls.count : 0; if (r) cbm_free_result(r); @@ -71,6 +77,9 @@ TEST(pylsp_scale_linear_growth) { double t100 = measure(100, &c100, &r100); double t500 = measure(500, &c500, &r500); double t2000 = measure(2000, &c2000, &r2000); + ASSERT(t100 >= 0.0); + ASSERT(t500 >= 0.0); + ASSERT(t2000 >= 0.0); printf(" scale: 100=%.1fms (calls=%d resolved=%d) 500=%.1fms (calls=%d resolved=%d) 2000=%.1fms (calls=%d resolved=%d)\n", t100, c100, r100, t500, c500, r500, t2000, c2000, r2000); diff --git a/tests/test_registry.c b/tests/test_registry.c index 725a6f935..2ee0c874d 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -10,6 +10,30 @@ #include #include +enum { + REGISTRY_HIGH_CARDINALITY_FIXTURE_COUNT = 300, + REGISTRY_LONG_IDENTITY_FILL_BYTES = CBM_SZ_512 + CBM_SZ_64, + REGISTRY_DISTINCT_LABEL_FIXTURE_COUNT = CBM_SZ_64 + 1, +}; + +static char *registry_long_identity(const char *prefix, char fill, const char *suffix) { + size_t prefix_len = strlen(prefix); + size_t suffix_len = strlen(suffix); + if (prefix_len > SIZE_MAX - REGISTRY_LONG_IDENTITY_FILL_BYTES || + prefix_len + REGISTRY_LONG_IDENTITY_FILL_BYTES > SIZE_MAX - suffix_len - SKIP_ONE) { + return NULL; + } + size_t size = prefix_len + REGISTRY_LONG_IDENTITY_FILL_BYTES + suffix_len + SKIP_ONE; + char *result = malloc(size); + if (!result) { + return NULL; + } + memcpy(result, prefix, prefix_len); + memset(result + prefix_len, fill, REGISTRY_LONG_IDENTITY_FILL_BYTES); + memcpy(result + prefix_len + REGISTRY_LONG_IDENTITY_FILL_BYTES, suffix, suffix_len + SKIP_ONE); + return result; +} + /* ── FQN computation ──────────────────────────────────────────────── */ TEST(fqn_simple) { @@ -326,6 +350,40 @@ TEST(resolve_qualified_ambiguous_tail_falls_through) { PASS(); } +TEST(resolve_qualified_imported_external_rejects_unreachable_suffix) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "Message", "proj.docs.additional.Message", "Class"); + cbm_registry_add(r, "Message", "proj.models.Message", "Class"); + cbm_registry_add(r, "Message", "proj.other.Message", "Class"); + + const char *keys[] = {"email.message"}; + const char *vals[] = {"email.message"}; + cbm_resolution_t res = + cbm_registry_resolve(r, "email.message.Message", "proj.fastapi.routing", keys, vals, 1); + ASSERT_TRUE(!res.qualified_name || res.qualified_name[0] == '\0'); + ASSERT_TRUE(!res.strategy || res.strategy[0] == '\0'); + + cbm_registry_free(r); + PASS(); +} + +TEST(resolve_dotted_receiver_rejects_unreachable_suffix_when_imports_exist) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "get", "proj.fastapi.routing.APIRouter.get", "Method"); + cbm_registry_add(r, "get", "proj.datastructures.Headers.get", "Method"); + cbm_registry_add(r, "get", "proj.other.Mapping.get", "Method"); + + const char *keys[] = {"Request"}; + const char *vals[] = {"starlette.requests.Request"}; + cbm_resolution_t res = + cbm_registry_resolve(r, "response.get", "proj.fastapi.routing", keys, vals, 1); + ASSERT_TRUE(!res.qualified_name || res.qualified_name[0] == '\0'); + ASSERT_TRUE(!res.strategy || res.strategy[0] == '\0'); + + cbm_registry_free(r); + PASS(); +} + TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); @@ -470,16 +528,36 @@ TEST(resolve_suffix_match) { PASS(); } -/* A name with more than REG_MAX_CANDIDATES (256) registered definitions is - * unresolvable by name alone: the candidate penalty floors its confidence to - * ~3/count (noise), while walking the candidate array per file dominated - * usage-resolution CPU on the Linux kernel ("flags"/"dev"/"list_head" have - * 4-7k definitions each). resolve must bail out with an empty result instead - * of scanning and emitting a near-zero-confidence edge. */ +TEST(resolve_suffix_match_tie_is_insertion_order_independent) { + cbm_registry_t *forward = cbm_registry_new(); + cbm_registry_t *reverse = cbm_registry_new(); + ASSERT_NOT_NULL(forward); + ASSERT_NOT_NULL(reverse); + + cbm_registry_add(forward, "store", "proj.alpha.Widget.store", "Field"); + cbm_registry_add(forward, "store", "proj.beta.Widget.store", "Field"); + cbm_registry_add(reverse, "store", "proj.beta.Widget.store", "Field"); + cbm_registry_add(reverse, "store", "proj.alpha.Widget.store", "Field"); + + cbm_resolution_t a = cbm_registry_resolve(forward, "store", "proj.header", NULL, NULL, 0); + cbm_resolution_t b = cbm_registry_resolve(reverse, "store", "proj.header", NULL, NULL, 0); + ASSERT_STR_EQ(a.strategy, "suffix_match"); + ASSERT_STR_EQ(b.strategy, "suffix_match"); + ASSERT_STR_EQ(a.qualified_name, "proj.alpha.Widget.store"); + ASSERT_STR_EQ(b.qualified_name, a.qualified_name); + + cbm_registry_free(forward); + cbm_registry_free(reverse); + PASS(); +} + +/* A high-cardinality bare name with no exact qualified/import signal remains + * unresolved: its confidence is noise, while scanning these names dominated + * usage-resolution CPU on the Linux kernel. */ TEST(resolve_caps_unresolvably_ambiguous_names) { cbm_registry_t *r = cbm_registry_new(); - for (int i = 0; i < 300; i++) { - char qn[64]; + for (int i = 0; i < REGISTRY_HIGH_CARDINALITY_FIXTURE_COUNT; i++) { + char qn[CBM_SZ_64]; snprintf(qn, sizeof(qn), "proj.mod%d.flags", i); cbm_registry_add(r, "flags", qn, "Variable"); } @@ -495,6 +573,154 @@ TEST(resolve_caps_unresolvably_ambiguous_names) { PASS(); } +/* Candidate cardinality is not a reason to discard an exact qualified-tail + * signal. The parent cap executes before Strategy 3.5 and loses this match. */ +TEST(resolve_high_cardinality_qualified_tail) { + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + for (int i = 0; i < REGISTRY_HIGH_CARDINALITY_FIXTURE_COUNT; i++) { + char qn[CBM_SZ_64]; + snprintf(qn, sizeof(qn), "proj.ns%d.run", i); + cbm_registry_add(r, "run", qn, "Function"); + } + + cbm_resolution_t res = cbm_registry_resolve(r, "ns299.run", "proj.caller", NULL, NULL, 0); + bool exact = res.qualified_name && strcmp(res.qualified_name, "proj.ns299.run") == 0 && + res.strategy && strcmp(res.strategy, "qualified_suffix") == 0; + cbm_registry_free(r); + ASSERT_TRUE(exact); + PASS(); +} + +/* An import-reachability signal can reduce an arbitrarily large by-name set to + * one exact candidate. The parent cap discards the signal before filtering. */ +TEST(resolve_high_cardinality_unique_import_reachable) { + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + for (int i = 0; i < REGISTRY_HIGH_CARDINALITY_FIXTURE_COUNT; i++) { + char qn[CBM_SZ_64]; + snprintf(qn, sizeof(qn), "proj.mod%d.flags", i); + cbm_registry_add(r, "flags", qn, "Variable"); + } + const char *import_keys[] = {"target"}; + const char *import_values[] = {"proj.mod299"}; + cbm_resolution_t res = + cbm_registry_resolve(r, "flags", "proj.caller", import_keys, import_values, 1); + bool exact = res.qualified_name && strcmp(res.qualified_name, "proj.mod299.flags") == 0 && + res.strategy && strcmp(res.strategy, "suffix_match") == 0; + cbm_registry_free(r); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(resolve_fuzzy_high_cardinality_unique_import_reachable) { + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + for (int i = 0; i < REGISTRY_HIGH_CARDINALITY_FIXTURE_COUNT; i++) { + char qn[CBM_SZ_64]; + snprintf(qn, sizeof(qn), "proj.mod%d.flags", i); + cbm_registry_add(r, "flags", qn, "Variable"); + } + const char *import_values[] = {"proj.mod299"}; + cbm_fuzzy_result_t result = + cbm_registry_fuzzy_resolve(r, "unknown.flags", "proj.caller", NULL, import_values, 1); + bool exact = result.ok && result.result.qualified_name && + strcmp(result.result.qualified_name, "proj.mod299.flags") == 0; + cbm_registry_free(r); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(registry_retains_more_than_parent_label_pool) { + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + for (int i = 0; i < REGISTRY_DISTINCT_LABEL_FIXTURE_COUNT; i++) { + char qn[CBM_SZ_64]; + char label[CBM_SZ_32]; + snprintf(qn, sizeof(qn), "proj.symbol%d", i); + snprintf(label, sizeof(label), "Label%d", i); + cbm_registry_add(r, "symbol", qn, label); + } + const char *last_label = cbm_registry_label_of(r, "proj.symbol64"); + bool exact = cbm_registry_size(r) == REGISTRY_DISTINCT_LABEL_FIXTURE_COUNT && last_label && + strcmp(last_label, "Label64") == 0; + cbm_registry_free(r); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(resolve_import_map_preserves_long_key_and_target) { + char *alias = registry_long_identity("alias", 'a', ""); + char *callee = registry_long_identity("alias", 'a', ".run"); + char *resolved = registry_long_identity("proj.", 'r', ""); + char *target = registry_long_identity("proj.", 'r', ".run"); + if (!alias || !callee || !resolved || !target) { + free(target); + free(resolved); + free(callee); + free(alias); + FAIL("long import fixture allocation"); + } + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + cbm_registry_add(r, "run", target, "Function"); + const char *keys[] = {alias}; + const char *values[] = {resolved}; + cbm_resolution_t result = cbm_registry_resolve(r, callee, "proj.caller", keys, values, 1); + bool exact = result.qualified_name && strcmp(result.qualified_name, target) == 0 && + result.strategy && strcmp(result.strategy, "import_map") == 0; + cbm_registry_free(r); + free(target); + free(resolved); + free(callee); + free(alias); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(resolve_same_module_preserves_long_identity) { + char *module = registry_long_identity("proj.", 'm', ""); + char *target = registry_long_identity("proj.", 'm', ".run"); + if (!module || !target) { + free(target); + free(module); + FAIL("long same-module fixture allocation"); + } + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + cbm_registry_add(r, "run", target, "Function"); + cbm_resolution_t result = cbm_registry_resolve(r, "run", module, NULL, NULL, 0); + bool exact = result.qualified_name && strcmp(result.qualified_name, target) == 0 && + result.strategy && strcmp(result.strategy, "same_module") == 0; + cbm_registry_free(r); + free(target); + free(module); + ASSERT_TRUE(exact); + PASS(); +} + +TEST(resolve_qualified_tail_preserves_long_identity) { + char *callee = registry_long_identity("ns.", 'q', ".run"); + char *target = registry_long_identity("proj.ns.", 'q', ".run"); + if (!callee || !target) { + free(target); + free(callee); + FAIL("long qualified-tail fixture allocation"); + } + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + cbm_registry_add(r, "run", target, "Function"); + cbm_registry_add(r, "run", "proj.other.run", "Function"); + cbm_resolution_t result = cbm_registry_resolve(r, callee, "proj.caller", NULL, NULL, 0); + bool exact = result.qualified_name && strcmp(result.qualified_name, target) == 0 && + result.strategy && strcmp(result.strategy, "qualified_suffix") == 0; + cbm_registry_free(r); + free(target); + free(callee); + ASSERT_TRUE(exact); + PASS(); +} + /* ── Import map suffix resolution ─────────────────────────────── */ TEST(resolve_import_map_suffix) { @@ -768,10 +994,18 @@ TEST(tsjs_suppress_drops_weak_method_matches) { * suffix_match / unique_name and the parallel field_type_hint; "fuzzy" is * covered defensively (cbm_registry_fuzzy_resolve is not wired into the * resolvers today) so a future wiring cannot silently reintroduce it. */ - ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "suffix_match")); - ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "unique_name")); - ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "field_type_hint")); - ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "fuzzy")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 1, "suffix_match")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_JAVASCRIPT, true, false, 1, "unique_name")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_TSX, true, false, 2, "field_type_hint")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "fuzzy")); + PASS(); +} + +TEST(registry_strategy_identifies_direct_import_map) { + ASSERT_TRUE(cbm_registry_strategy_is_import_map("import_map")); + ASSERT_FALSE(cbm_registry_strategy_is_import_map("import_map_suffix")); + ASSERT_FALSE(cbm_registry_strategy_is_import_map("same_module")); + ASSERT_FALSE(cbm_registry_strategy_is_import_map(NULL)); PASS(); } @@ -783,23 +1017,42 @@ TEST(tsjs_suppress_keeps_high_confidence_and_non_methods) { * enumerates the resolver's non-weak strategies: registry * {import_map, import_map_suffix, same_module, qualified_suffix}, parallel * {callee_suffix, service_pattern}, and lsp_*. */ - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "same_module")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "import_map")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "import_map_suffix")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "qualified_suffix")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "callee_suffix")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "service_pattern")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "lsp_ts_method")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "lsp_cross")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "lsp_ts_local")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "same_module")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "import_map")); + ASSERT_FALSE( + cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "import_map_suffix")); + ASSERT_FALSE( + cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "qualified_suffix")); + ASSERT_FALSE( + cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "callee_suffix")); + ASSERT_FALSE( + cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "service_pattern")); + ASSERT_FALSE( + cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "lsp_ts_method")); + ASSERT_FALSE( + cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 2, "lsp_method_dispatch")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 2, "lsp_cross")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "lsp_ts_local")); /* A bare call (is_method=false) is a free-function call → never suppressed. */ - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, false, "unique_name")); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, false, "suffix_match")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, false, false, 2, "unique_name")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_RUST, false, false, 2, "suffix_match")); /* Non-TS/JS languages are never affected. */ - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(false, true, "suffix_match")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_GO, true, false, 2, "suffix_match")); /* No match (NULL/empty strategy) → nothing to suppress. */ - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, NULL)); - ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, NULL)); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_TYPESCRIPT, true, false, 2, "")); + PASS(); +} + +TEST(rust_suppress_drops_only_ambiguous_weak_member_matches) { + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 2, "suffix_match")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 2, "fuzzy")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 2, "field_type_hint")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 1, "unique_name")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_RUST, true, false, 1, "suffix_match")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_RUST, false, true, 1, "unique_name")); + ASSERT_TRUE(cbm_suppress_weak_call_match(CBM_LANG_RUST, false, true, 2, "suffix_match")); + ASSERT_FALSE(cbm_suppress_weak_call_match(CBM_LANG_RUST, false, true, 2, "lsp_macro")); PASS(); } @@ -827,6 +1080,31 @@ TEST(resolve_import_map_alias_with_suffix_hits_method) { PASS(); } +/* A cfg predicate is part of a Rust definition's graph identity, but not its + * source-level call name. Index both cfg-gated twins under the supplied name + * so a local call cannot make an unrelated cross-module definition appear to + * be the sole candidate. */ +TEST(resolve_cfg_gated_twins_by_source_name) { + cbm_registry_t *r = cbm_registry_new(); + ASSERT_NOT_NULL(r); + cbm_registry_add(r, "has_permission", + "proj.scripts.helpers.has_permission#cfg(target_os=\"macos\")", "Function"); + cbm_registry_add(r, "has_permission", + "proj.scripts.helpers.has_permission#cfg(not(target_os=\"macos\"))", + "Function"); + cbm_registry_add(r, "has_permission", "proj.engine.permissions.has_permission", "Function"); + + cbm_resolution_t res = + cbm_registry_resolve(r, "has_permission", "proj.scripts.helpers", NULL, NULL, 0); + ASSERT_NOT_NULL(res.qualified_name); + ASSERT_NOT_NULL(strstr(res.qualified_name, "proj.scripts.helpers.has_permission#cfg(")); + ASSERT_STR_EQ(res.strategy, "suffix_match"); + ASSERT_EQ(res.candidate_count, 3); + + cbm_registry_free(r); + PASS(); +} + SUITE(registry) { /* FQN */ RUN_TEST(fqn_simple); @@ -857,10 +1135,13 @@ SUITE(registry) { RUN_TEST(resolve_same_module); RUN_TEST(resolve_qualified_disambiguates_same_name); RUN_TEST(resolve_qualified_ambiguous_tail_falls_through); + RUN_TEST(resolve_qualified_imported_external_rejects_unreachable_suffix); + RUN_TEST(resolve_dotted_receiver_rejects_unreachable_suffix_when_imports_exist); RUN_TEST(resolve_import_map); RUN_TEST(resolve_import_map_bare_function); RUN_TEST(resolve_import_map_bare_alias); RUN_TEST(resolve_import_map_alias_with_suffix_hits_method); + RUN_TEST(resolve_cfg_gated_twins_by_source_name); RUN_TEST(resolve_unique_name); RUN_TEST(resolve_unresolved); RUN_TEST(resolve_many_nodes); @@ -870,7 +1151,15 @@ SUITE(registry) { RUN_TEST(confidence_band_speculative); /* Suffix match + import map suffix */ RUN_TEST(resolve_suffix_match); + RUN_TEST(resolve_suffix_match_tie_is_insertion_order_independent); RUN_TEST(resolve_caps_unresolvably_ambiguous_names); + RUN_TEST(resolve_high_cardinality_qualified_tail); + RUN_TEST(resolve_high_cardinality_unique_import_reachable); + RUN_TEST(resolve_fuzzy_high_cardinality_unique_import_reachable); + RUN_TEST(registry_retains_more_than_parent_label_pool); + RUN_TEST(resolve_import_map_preserves_long_key_and_target); + RUN_TEST(resolve_same_module_preserves_long_identity); + RUN_TEST(resolve_qualified_tail_preserves_long_identity); RUN_TEST(resolve_import_map_suffix); /* Import reachability */ RUN_TEST(resolve_is_import_reachable); @@ -894,5 +1183,7 @@ SUITE(registry) { RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches); RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls); RUN_TEST(tsjs_suppress_drops_weak_method_matches); + RUN_TEST(registry_strategy_identifies_direct_import_map); RUN_TEST(tsjs_suppress_keeps_high_confidence_and_non_methods); + RUN_TEST(rust_suppress_drops_only_ambiguous_weak_member_matches); } diff --git a/tests/test_route_canon.c b/tests/test_route_canon.c index 206dd8fc6..26403c451 100644 --- a/tests/test_route_canon.c +++ b/tests/test_route_canon.c @@ -9,6 +9,7 @@ */ #include "test_framework.h" #include "pipeline/pipeline_internal.h" +#include #include @@ -93,6 +94,55 @@ TEST(route_canon_truncation_safe) { PASS(); } +TEST(route_identity_http_default_is_canonical_json) { + char qn[CBM_ROUTE_QN_SIZE]; + char props[CBM_SZ_256]; + ASSERT_TRUE(cbm_pipeline_build_service_route_identity( + "/players/:id", CBM_SVC_HTTP, NULL, NULL, "arg_url", qn, sizeof(qn), props, + sizeof(props))); + ASSERT_STR_EQ(qn, "__route__ANY__/players/{}"); + ASSERT_NOT_NULL(strstr(props, "\"method\":\"" CBM_ROUTE_DEFAULT_METHOD "\"")); + ASSERT_NOT_NULL(strstr(props, "\"source\":\"arg_url\"")); + yyjson_doc *doc = yyjson_read(props, strlen(props), 0); + ASSERT_NOT_NULL(doc); + yyjson_doc_free(doc); + PASS(); +} + +TEST(route_identity_source_is_escaped_json) { + char qn[CBM_ROUTE_QN_SIZE]; + char props[CBM_SZ_256]; + ASSERT_TRUE(cbm_pipeline_build_service_route_identity( + "/api/orders", CBM_SVC_HTTP, "GET", NULL, "decor\"ator", qn, sizeof(qn), props, + sizeof(props))); + ASSERT_STR_EQ(qn, "__route__GET__/api/orders"); + ASSERT_NOT_NULL(strstr(props, "\"source\":\"decor\\\"ator\"")); + yyjson_doc *doc = yyjson_read(props, strlen(props), 0); + ASSERT_NOT_NULL(doc); + yyjson_doc_free(doc); + PASS(); +} + +TEST(route_identity_async_default_is_canonical_json) { + char qn[CBM_ROUTE_QN_SIZE]; + char props[CBM_SZ_256]; + ASSERT_TRUE(cbm_pipeline_build_service_route_identity( + "orders.created", CBM_SVC_ASYNC, NULL, NULL, NULL, qn, sizeof(qn), props, + sizeof(props))); + ASSERT_STR_EQ(qn, "__route__" CBM_ROUTE_DEFAULT_ASYNC_BROKER "__orders.created"); + ASSERT_STR_EQ(props, "{\"broker\":\"" CBM_ROUTE_DEFAULT_ASYNC_BROKER "\"}"); + PASS(); +} + +TEST(route_identity_rejects_unknown_service_kind) { + char qn[CBM_ROUTE_QN_SIZE]; + char props[CBM_SZ_256]; + ASSERT_FALSE(cbm_pipeline_build_service_route_identity( + "/api/orders", CBM_SVC_CONFIG, NULL, NULL, NULL, qn, sizeof(qn), props, + sizeof(props))); + PASS(); +} + SUITE(route_canon) { RUN_TEST(route_canon_static_unchanged); RUN_TEST(route_canon_colon_param); @@ -105,4 +155,8 @@ SUITE(route_canon) { RUN_TEST(route_canon_colon_mid_segment_is_literal); RUN_TEST(route_canon_null_and_empty); RUN_TEST(route_canon_truncation_safe); + RUN_TEST(route_identity_http_default_is_canonical_json); + RUN_TEST(route_identity_source_is_escaped_json); + RUN_TEST(route_identity_async_default_is_canonical_json); + RUN_TEST(route_identity_rejects_unknown_service_kind); } diff --git a/tests/test_run_benchmark.py b/tests/test_run_benchmark.py new file mode 100644 index 000000000..c92540f81 --- /dev/null +++ b/tests/test_run_benchmark.py @@ -0,0 +1,3050 @@ +import importlib.util +from contextlib import closing +import gzip +import json +import os +import sqlite3 +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest import mock +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "benchmarks" / "run_benchmark.py" +SPEC = importlib.util.spec_from_file_location("run_benchmark", SCRIPT) +assert SPEC and SPEC.loader +BENCHMARK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BENCHMARK) + + +class RunBenchmarkTest(unittest.TestCase): + def test_mcp_overhead_probes_forward_indexed_project_arguments(self) -> None: + client = mock.Mock() + client.call_tool.return_value = ({"status": "indexed"}, "", 17, 1.25) + arguments = {"project": "/isolated/repo"} + + result = BENCHMARK.measure_mcp_overhead_probes( + client, + "index_status", + 2, + False, + arguments, + ) + + self.assertEqual(result["summary"]["count"], 2) + self.assertEqual( + client.call_tool.call_args_list, + [ + mock.call("index_status", arguments), + mock.call("index_status", arguments), + ], + ) + + def test_indexed_query_probes_bind_the_named_project_over_mcp(self) -> None: + client = mock.Mock() + client.call_tool.return_value = ({"status": "ready"}, "", 17, 1.25) + + result = BENCHMARK.measure_indexed_query_probes_for_transport( + "mcp", + Path("/candidate/cbm"), + {}, + "index_status", + 2, + "stable-project", + 30, + False, + client, + ) + + self.assertEqual(result["summary"]["count"], 2) + self.assertEqual( + client.call_tool.call_args_list, + [ + mock.call("index_status", {"project": "stable-project"}), + mock.call("index_status", {"project": "stable-project"}), + ], + ) + + def test_indexed_query_probes_summarize_daemon_memory_census_window( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) + daemon_log = cache / BENCHMARK.DAEMON_LOG_RELATIVE_PATH + daemon_log.parent.mkdir(parents=True) + daemon_log.write_text("level=info msg=preexisting rss_kb=999\n") + samples = iter( + [ + "level=info msg=mem.census at=mcp.request mi_area_kb=80 " + "mi_live_kb=40 rss_kb=100\n", + "level=info msg=mem.census at=mcp.request mi_area_kb=85 " + "mi_live_kb=41 rss_kb=120\n", + ] + ) + client = mock.Mock() + + def call_tool(_tool: str, _arguments: dict[str, object]): + with daemon_log.open("a", encoding="utf-8") as stream: + stream.write(next(samples)) + return {"status": "ready"}, "", 17, 1.25 + + client.call_tool.side_effect = call_tool + + result = BENCHMARK.measure_indexed_query_probes_for_transport( + "mcp", + Path("/candidate/cbm"), + {"CBM_CACHE_DIR": str(cache)}, + "index_status", + 2, + "stable-project", + 30, + False, + client, + ) + + self.assertEqual( + result["daemon_mem_census"], + { + "count": 2, + "rss_kb": { + "first": 100, + "last": 120, + "min": 100, + "max": 120, + "delta": 20, + }, + "mi_area_kb": { + "first": 80, + "last": 85, + "min": 80, + "max": 85, + "delta": 5, + }, + "mi_live_kb": { + "first": 40, + "last": 41, + "min": 40, + "max": 41, + "delta": 1, + }, + }, + ) + + def test_indexed_query_probes_summarize_matching_daemon_profile_window( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) + daemon_log = cache / BENCHMARK.DAEMON_LOG_RELATIVE_PATH + daemon_log.parent.mkdir(parents=True) + daemon_log.write_text( + "level=info msg=prof phase=mcp_request_total sub=tools/call us=999\n" + ) + client = mock.Mock() + + def call_tool(_tool: str, _arguments: dict[str, object]): + with daemon_log.open("a", encoding="utf-8") as stream: + stream.write( + "level=info msg=prof phase=mcp_tool_execute " + "sub=index_status ms=0 us=120\n" + "level=info msg=prof phase=mcp_request_total " + "sub=tools/call ms=0 us=180\n" + "level=info msg=prof phase=mcp_tool_execute " + "sub=search_graph ms=8 us=8000\n" + "level=info msg=prof phase=mcp_tool_execute " + "sub=index_status ms=0 us=140\n" + "level=info msg=prof phase=mcp_request_total " + "sub=tools/call ms=0 us=220\n" + "level=info msg=prof phase=resolve_store " + "sub=open_validate ms=0 us=75\n" + "level=info msg=prof phase=request_release_store " + "sub=store_close ms=0 us=55\n" + "level=info msg=prof phase=index_status " + "sub=graph_counts ms=6 us=6100\n" + "level=info msg=prof phase=mcp_request_total " + "sub=tools/call us=not-a-number\n" + ) + return {"status": "ready"}, "", 17, 1.25 + + client.call_tool.side_effect = call_tool + result = BENCHMARK.measure_indexed_query_probes_for_transport( + "mcp", + Path("/candidate/cbm"), + {"CBM_CACHE_DIR": str(cache)}, + "index_status", + 1, + "stable-project", + 30, + False, + client, + ) + + self.assertEqual( + result["daemon_profile"], + { + "mcp_tool_execute/index_status": { + "count": 2, + "total_us": 260, + "min_us": 120, + "max_us": 140, + "mean_us": 130.0, + }, + "mcp_request_total/tools/call": { + "count": 2, + "total_us": 400, + "min_us": 180, + "max_us": 220, + "mean_us": 200.0, + }, + "resolve_store/open_validate": { + "count": 1, + "total_us": 75, + "min_us": 75, + "max_us": 75, + "mean_us": 75.0, + }, + "request_release_store/store_close": { + "count": 1, + "total_us": 55, + "min_us": 55, + "max_us": 55, + "mean_us": 55.0, + }, + "index_status/graph_counts": { + "count": 1, + "total_us": 6100, + "min_us": 6100, + "max_us": 6100, + "mean_us": 6100.0, + }, + }, + ) + + def test_find_project_db_ignores_dependency_databases(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) + primary = cache / "primary.db" + primary.touch() + (cache / "primary.dep.mimalloc.db").touch() + (cache / "primary.dep.tree-sitter.db").touch() + (cache / BENCHMARK.CONFIG_DB_NAME).touch() + + self.assertEqual(BENCHMARK.find_project_db(cache), primary) + + def test_find_project_db_accepts_dotted_primary_name(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) + primary = cache / "project.config.db" + primary.touch() + + self.assertEqual(BENCHMARK.find_project_db(cache), primary) + + def test_find_project_db_rejects_missing_primary_with_dependency_list(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) + (cache / "primary.dep.mimalloc.db").touch() + + with self.assertRaisesRegex( + RuntimeError, + r"expected one primary project DB.*found 0.*primary\.dep\.mimalloc\.db", + ): + BENCHMARK.find_project_db(cache) + + def test_find_project_db_rejects_ambiguous_primary_databases(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) + (cache / "first.db").touch() + (cache / "second.db").touch() + (cache / "first.dep.mimalloc.db").touch() + + with self.assertRaisesRegex( + RuntimeError, + r"expected one primary project DB.*found 2.*first\.db, second\.db", + ): + BENCHMARK.find_project_db(cache) + + def test_build_env_rejects_inherited_live_cache_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + live_cache = Path(tmpdir) / "live-cache" + live_cache.mkdir() + with mock.patch.dict( + os.environ, {"CBM_CACHE_DIR": str(live_cache)}, clear=False + ): + with self.assertRaisesRegex(RuntimeError, "live cache"): + BENCHMARK.build_env(live_cache) + + def test_build_env_rejects_cache_with_existing_project_database(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + cache = Path(tmpdir) / "candidate-cache" + cache.mkdir() + (cache / f"existing{BENCHMARK.PROJECT_DB_SUFFIX}").touch() + + with self.assertRaisesRegex(RuntimeError, "existing project database"): + BENCHMARK.build_env(cache) + + def test_declared_stale_views_are_collected_from_tool_responses(self) -> None: + oracles = { + "search": { + "freshness": { + "state": "stale_with_warning", + "stale_views": ["semantic_edges", "pagerank"], + } + }, + "architecture": { + "freshness": { + "state": "stale_with_warning", + "stale_views": ["architecture", "pagerank"], + } + }, + "quality": {"passed": True}, + } + + self.assertEqual( + BENCHMARK.declared_stale_views(oracles), + ["architecture", "pagerank", "semantic_edges"], + ) + + def test_persisted_stale_views_are_read_from_canonical_freshness_ledger( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + database = Path(tmpdir) / "graph.db" + with closing(sqlite3.connect(database)) as con, con: + con.execute( + "CREATE TABLE derived_view_state(" + "project TEXT, view_name TEXT, source_generation INTEGER, " + "computed_at INTEGER, status TEXT, detail TEXT, " + "PRIMARY KEY(project, view_name))" + ) + con.executemany( + "INSERT INTO derived_view_state VALUES (?,?,?,?,?,?)", + [ + ("repo", "semantic_edges", 2, 1, "stale", ""), + ("repo", "pagerank", 2, 2, "complete", ""), + ("other", "routes", 2, 1, "stale", ""), + ], + ) + + self.assertEqual( + BENCHMARK.persisted_stale_views(database, "repo"), + ["semantic_edges"], + ) + + def test_declared_stale_semantic_edges_preserve_core_graph_gate(self) -> None: + gate = BENCHMARK.graph_gate_for_publish_kind( + {"equal": False}, + BENCHMARK.PUBLISH_INCREMENTAL_EXACT, + freshness_scoped={ + "equal": True, + "declared_stale_views": ["semantic_edges"], + "excluded_edge_types": ["SEMANTICALLY_RELATED"], + }, + ) + + self.assertTrue(gate["passed"]) + self.assertEqual(gate["policy"], "declared_stale_derived_views") + self.assertFalse(gate["canonical_equal"]) + self.assertTrue(gate["freshness_scoped_equal"]) + self.assertEqual(gate["declared_stale_views"], ["semantic_edges"]) + + def test_canonical_equality_takes_precedence_over_stale_ledger(self) -> None: + gate = BENCHMARK.graph_gate_for_publish_kind( + {"equal": True}, + BENCHMARK.PUBLISH_INCREMENTAL_EXACT, + freshness_scoped={ + "equal": True, + "declared_stale_views": ["semantic_edges"], + "excluded_edge_types": ["SEMANTICALLY_RELATED"], + }, + ) + + self.assertTrue(gate["passed"]) + self.assertEqual(gate["policy"], "canonical_graph") + self.assertTrue(gate["canonical_equal"]) + self.assertNotIn("declared_stale_views", gate) + + def test_declared_stale_semantic_edges_do_not_hide_core_graph_mismatch( + self, + ) -> None: + gate = BENCHMARK.graph_gate_for_publish_kind( + {"equal": False}, + BENCHMARK.PUBLISH_INCREMENTAL_EXACT, + freshness_scoped={ + "equal": False, + "kind": "canonical edges excluding declared stale views", + "declared_stale_views": ["semantic_edges"], + "excluded_edge_types": ["SEMANTICALLY_RELATED"], + }, + ) + + self.assertFalse(gate["passed"]) + self.assertEqual(gate["policy"], "canonical_graph") + + def test_freshness_scoped_comparison_excludes_only_semantic_edges(self) -> None: + def create_graph(database: Path, extra_type: str) -> None: + with closing(sqlite3.connect(database)) as con, con: + con.execute( + "CREATE TABLE nodes(" + "id INTEGER PRIMARY KEY, project TEXT, label TEXT, name TEXT, " + "qualified_name TEXT, file_path TEXT, start_line INTEGER, end_line INTEGER, " + "properties TEXT)" + ) + con.execute( + "CREATE TABLE edges(" + "project TEXT, source_id INTEGER, target_id INTEGER, type TEXT, properties TEXT)" + ) + con.execute( + "CREATE TABLE file_hashes(" + "project TEXT, rel_path TEXT, sha256 TEXT, mtime_ns INTEGER, size INTEGER)" + ) + con.executemany( + "INSERT INTO nodes VALUES (?,?,?,?,?,?,?,?,?)", + [ + (1, "repo", "Function", "left", "repo.left", "a.c", 1, 2, "{}"), + ( + 2, + "repo", + "Function", + "right", + "repo.right", + "a.c", + 4, + 5, + "{}", + ), + ], + ) + con.execute( + "INSERT INTO edges VALUES ('repo',1,2,?,?)", + ( + extra_type, + '{"score":0.75}' + if extra_type == "SEMANTICALLY_RELATED" + else "{}", + ), + ) + con.execute("INSERT INTO file_hashes VALUES ('repo','a.c','abc',1,10)") + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + empty = root / "empty.db" + semantic = root / "semantic.db" + core = root / "core.db" + create_graph(empty, "SEMANTICALLY_RELATED") + with closing(sqlite3.connect(empty)) as con, con: + con.execute("DELETE FROM edges") + create_graph(semantic, "SEMANTICALLY_RELATED") + create_graph(core, "CALLS") + + semantic_result = BENCHMARK.compare_graph_excluding_declared_stale_views( + semantic, empty, "repo", ["semantic_edges"] + ) + core_result = BENCHMARK.compare_graph_excluding_declared_stale_views( + core, empty, "repo", ["semantic_edges"] + ) + + self.assertIsNotNone(semantic_result) + self.assertTrue(semantic_result["equal"]) + self.assertIsNotNone(core_result) + self.assertFalse(core_result["equal"]) + self.assertEqual( + core_result["kind"], "canonical edges excluding declared stale views" + ) + + def test_cli_default_preserves_candidate_rank_refresh_policy(self) -> None: + with mock.patch.object(sys, "argv", [str(SCRIPT)]): + args = BENCHMARK.parse_args() + + self.assertEqual(args.rank_refresh, BENCHMARK.RANK_REFRESH_CANDIDATE_DEFAULT) + + def test_candidate_default_rank_refresh_does_not_write_config_override( + self, + ) -> None: + with mock.patch.object(BENCHMARK, "run_config_set") as run: + applied = BENCHMARK.apply_rank_refresh_override( + Path("/tmp/cbm"), {}, BENCHMARK.RANK_REFRESH_CANDIDATE_DEFAULT, 30 + ) + + self.assertFalse(applied) + run.assert_not_called() + + def test_explicit_rank_refresh_writes_config_override(self) -> None: + with mock.patch.object(BENCHMARK, "run_config_set") as run: + applied = BENCHMARK.apply_rank_refresh_override( + Path("/tmp/cbm"), {}, "defer_exact_delta_reindexes", 30 + ) + + self.assertTrue(applied) + run.assert_called_once_with( + Path("/tmp/cbm"), {}, "rank_refresh", "defer_exact_delta_reindexes", 30 + ) + + def test_config_set_probes_registered_rank_policy_once_for_pre_rename_candidate( + self, + ) -> None: + binary = Path("/tmp/cbm-legacy") + failed = subprocess.CompletedProcess([], 1, stdout="", stderr="unsupported") + passed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + BENCHMARK.CONFIG_SPELLING_MODES.clear() + self.addCleanup(BENCHMARK.CONFIG_SPELLING_MODES.clear) + with ( + mock.patch.object( + BENCHMARK, + "candidate_binary_identity", + return_value=("legacy", 1, 2), + ), + mock.patch.object( + BENCHMARK, + "command_result", + side_effect=[ + (failed, 1.0), + (passed, 1.0), + (passed, 1.0), + (passed, 1.0), + ], + ) as run, + ): + BENCHMARK.run_config_set( + binary, + {}, + "incremental_derived_results_refresh", + "at_publish", + 30, + ) + BENCHMARK.run_config_set( + binary, + {}, + "rank_refresh", + "defer_exact_delta_reindexes", + 30, + ) + + self.assertEqual( + [call.args[0][3:] for call in run.call_args_list], + [ + ["rank_refresh", "defer_all_incremental_reindexes"], + ["rank_refresh", "stale_on_incremental"], + ["incremental_derived_refresh", "eager"], + ["rank_refresh", "stale_on_exact"], + ], + ) + probe_envs = [run.call_args_list[index].args[1] for index in (0, 1)] + self.assertTrue(all(env.get("CBM_CACHE_DIR") for env in probe_envs)) + self.assertEqual(probe_envs[0]["CBM_CACHE_DIR"], probe_envs[1]["CBM_CACHE_DIR"]) + self.assertEqual(run.call_args_list[2].args[1], {}) + self.assertEqual(run.call_args_list[3].args[1], {}) + + def test_config_spelling_probe_runs_once_across_concurrent_workers(self) -> None: + binary = Path("/tmp/cbm-current") + passed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + barrier = threading.Barrier(3) + modes: list[str] = [] + BENCHMARK.CONFIG_SPELLING_MODES.clear() + self.addCleanup(BENCHMARK.CONFIG_SPELLING_MODES.clear) + + def worker() -> None: + barrier.wait() + modes.append(BENCHMARK.config_spelling_mode(binary, {}, 30)) + + with ( + mock.patch.object( + BENCHMARK, + "candidate_binary_identity", + return_value=("current", 1, 2), + ), + mock.patch.object( + BENCHMARK, "command_result", return_value=(passed, 1.0) + ) as run, + ): + workers = [threading.Thread(target=worker) for _ in range(2)] + for thread in workers: + thread.start() + barrier.wait() + for thread in workers: + thread.join() + + self.assertEqual(modes, [BENCHMARK.CONFIG_SPELLING_CANONICAL] * 2) + run.assert_called_once() + + def test_config_set_skips_spelling_probe_for_unchanged_key_value(self) -> None: + passed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + with ( + mock.patch.object(BENCHMARK, "config_spelling_mode") as probe, + mock.patch.object( + BENCHMARK, "command_result", return_value=(passed, 1.0) + ) as run, + ): + BENCHMARK.run_config_set( + Path("/tmp/cbm-upstream"), {}, "auto_index_deps", "false", 30 + ) + + probe.assert_not_called() + self.assertEqual(run.call_args.args[0][3:], ["auto_index_deps", "false"]) + + def test_stream_query_fingerprint_is_ordered_bounded_and_change_sensitive( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + database = Path(tmpdir) / "graph.db" + with closing(sqlite3.connect(database)) as con, con: + con.execute("CREATE TABLE rows(value TEXT NOT NULL)") + con.executemany("INSERT INTO rows VALUES (?)", [("beta",), ("alpha",)]) + + first = BENCHMARK.stream_query_fingerprint( + database, "SELECT value FROM rows ORDER BY value", () + ) + second = BENCHMARK.stream_query_fingerprint( + database, "SELECT value FROM rows ORDER BY value", () + ) + with closing(sqlite3.connect(database)) as con, con: + con.execute("INSERT INTO rows VALUES ('gamma')") + changed = BENCHMARK.stream_query_fingerprint( + database, "SELECT value FROM rows ORDER BY value", () + ) + + self.assertEqual(first, second) + self.assertEqual(first["row_count"], 2) + self.assertEqual(len(first["sha256"]), 64) + self.assertEqual(changed["row_count"], 3) + self.assertNotEqual(changed["sha256"], first["sha256"]) + + def test_content_fingerprint_excludes_volatile_file_mtime(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + fingerprints = [] + canonical_hashes = [] + for index, mtime_ns in enumerate((100, 900)): + database = Path(tmpdir) / f"graph-{index}.db" + with closing(sqlite3.connect(database)) as con, con: + con.execute( + "CREATE TABLE file_hashes(" + "project TEXT, rel_path TEXT, sha256 TEXT, mtime_ns INTEGER, size INTEGER)" + ) + con.execute( + "INSERT INTO file_hashes VALUES ('repo','src/a.c','abc',?,12)", + (mtime_ns,), + ) + fingerprints.append( + BENCHMARK.stream_query_fingerprint( + database, BENCHMARK.CONTENT_HASHES_SQL, ("repo",) + ) + ) + canonical_hashes.append( + BENCHMARK.stream_query_fingerprint( + database, BENCHMARK.CANONICAL_HASHES_SQL, ("repo",) + ) + ) + + self.assertEqual(fingerprints[0], fingerprints[1]) + self.assertNotEqual(canonical_hashes[0], canonical_hashes[1]) + + def test_graph_fingerprint_normalizes_project_root_but_retains_semantic_score( + self, + ) -> None: + def create_graph(database: Path, project: str, score: float) -> None: + with closing(sqlite3.connect(database)) as con, con: + con.execute( + "CREATE TABLE nodes(" + "id INTEGER PRIMARY KEY, project TEXT, label TEXT, name TEXT, " + "qualified_name TEXT, file_path TEXT, start_line INTEGER, end_line INTEGER, " + "properties TEXT)" + ) + con.execute( + "CREATE TABLE edges(" + "project TEXT, source_id INTEGER, target_id INTEGER, type TEXT, properties TEXT)" + ) + con.execute( + "CREATE TABLE file_hashes(" + "project TEXT, rel_path TEXT, sha256 TEXT, mtime_ns INTEGER, size INTEGER)" + ) + con.executemany( + "INSERT INTO nodes VALUES (?,?,?,?,?,?,?,?,?)", + [ + ( + 1, + project, + "Function", + "left", + f"{project}.pkg.left", + "src/a.py", + 1, + 2, + json.dumps({"checkout": f"/tmp/{project}"}), + ), + ( + 2, + project, + "Function", + "right", + f"{project}.pkg.right", + "src/a.py", + 4, + 5, + json.dumps({"checkout": f"/tmp/{project}"}), + ), + ( + 3, + project, + "Project", + project, + project, + "", + 0, + 0, + json.dumps({"root": f"/tmp/{project}"}), + ), + ], + ) + con.execute( + "INSERT INTO edges VALUES (?,?,?,?,?)", + ( + project, + 1, + 2, + "SEMANTICALLY_RELATED", + json.dumps({"score": score}), + ), + ) + con.execute( + "INSERT INTO file_hashes VALUES (?,?,?,?,?)", + (project, "src/a.py", "content-sha", 123, 42), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + left_db = Path(tmpdir) / "left.db" + right_db = Path(tmpdir) / "right.db" + create_graph(left_db, "random-root-a", 0.873) + create_graph(right_db, "random-root-b", 0.873) + + left = BENCHMARK.stable_graph_fingerprint(left_db, "random-root-a") + right = BENCHMARK.stable_graph_fingerprint(right_db, "random-root-b") + self.assertEqual(left, right) + + with closing(sqlite3.connect(right_db)) as con, con: + con.execute( + "UPDATE edges SET properties = ?", + (json.dumps({"score": 0.811}),), + ) + changed = BENCHMARK.stable_graph_fingerprint(right_db, "random-root-b") + + self.assertEqual(left["components"]["nodes"], changed["components"]["nodes"]) + self.assertEqual(left["components"]["edges"], changed["components"]["edges"]) + self.assertNotEqual( + left["components"]["semantic_scores"], + changed["components"]["semantic_scores"], + ) + + def test_archive_measurement_log_streams_reproducible_gzip_with_hashes( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + source = root / "worker.log" + artifacts = root / "artifacts" + payload = ("level=info msg=mem.phase peak_mb=64\n" * 100).encode() + source.write_bytes(payload) + + first = BENCHMARK.archive_measurement_log(source, artifacts) + second = BENCHMARK.archive_measurement_log(source, artifacts) + archive = artifacts / first["artifact_name"] + + self.assertEqual(first, second) + self.assertEqual(gzip.decompress(archive.read_bytes()), payload) + self.assertEqual(first["source_bytes"], len(payload)) + self.assertEqual(len(first["source_sha256"]), 64) + self.assertEqual(len(first["artifact_sha256"]), 64) + self.assertEqual(len(list(artifacts.glob("*.log.gz"))), 1) + + def test_error_detail_archived_log_is_retained_in_artifact_facts(self) -> None: + archived = { + "artifact_name": "a" * 64 + ".log.gz", + "artifact_bytes": 2306, + "artifact_sha256": "b" * 64, + "source_bytes": 8750, + "source_name": ".worker-log-example", + "source_sha256": "c" * 64, + "compression": "gzip-mtime-0", + } + facts = BENCHMARK.normalize_benchmark_report( + { + "error": "worker failed", + "error_detail": {"measurement_log_artifacts": [archived]}, + } + ) + + self.assertEqual(len(facts["artifacts"]), 1) + artifact = facts["artifacts"][0] + self.assertEqual(artifact["artifact_type"], "measurement_log") + self.assertEqual(artifact["path"], archived["artifact_name"]) + self.assertEqual(artifact["sha256"], archived["artifact_sha256"]) + self.assertEqual(artifact["size_bytes"], archived["artifact_bytes"]) + + def test_run_index_mcp_archives_worker_log_before_raising_decode_error( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + cache = root / "cache" + cache.mkdir() + worker_log = cache / "logs with spaces" / ".worker-log-preserved" + worker_log.parent.mkdir() + worker_log.write_text( + "level=error msg=store.open_failed path=project.db\n", + encoding="utf-8", + ) + artifact_dir = root / "durable-artifacts" + + class FakeClient: + env = {"CBM_CACHE_DIR": str(cache)} + + def call_tool_text(self, name, arguments): + return ( + f"index worker ended with exit_nonzero; inspect log: {worker_log}\n", + "", + 127, + 8.5, + ) + + with mock.patch.dict( + os.environ, {BENCHMARK.BENCHMARK_ARTIFACT_DIR_ENV: str(artifact_dir)} + ): + with self.assertRaises(BENCHMARK.BenchmarkCommandError) as raised: + BENCHMARK.run_index_mcp( + FakeClient(), root / "repo", include_logs=True + ) + + detail = raised.exception.detail + artifact = detail["measurement_log_artifacts"][0] + archived_path = artifact_dir / artifact["artifact_name"] + worker_log.unlink() + + self.assertTrue(archived_path.is_file()) + self.assertIn( + "msg=store.open_failed", + gzip.decompress(archived_path.read_bytes()).decode(), + ) + self.assertEqual(detail["label"], "index_repository") + self.assertEqual( + detail["response_text_tail"][-1], + "index worker ended with exit_nonzero; inspect log: " + str(worker_log), + ) + + def test_copy_git_revision_to_dir_excludes_dirty_and_untracked_source_state( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + source = root / "source" + destination = root / "destination" + source.mkdir() + subprocess.run(["git", "init", "-q"], cwd=source, check=True) + subprocess.run( + ["git", "config", "user.email", "benchmark@example.invalid"], + cwd=source, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Benchmark Fixture"], + cwd=source, + check=True, + ) + (source / "tracked.py").write_text("VERSION = 1\n", encoding="utf-8") + task_source = source / "benchmarks" / "semantic-pairs-v1" / "canary.py" + task_source.parent.mkdir(parents=True) + task_source.write_text("DUPLICATE = True\n", encoding="utf-8") + subprocess.run( + ["git", "add", "tracked.py", str(task_source.relative_to(source))], + cwd=source, + check=True, + ) + subprocess.run( + ["git", "commit", "-q", "-m", "fixture"], cwd=source, check=True + ) + revision = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=source, + check=True, + text=True, + capture_output=True, + ).stdout.strip() + (source / "tracked.py").write_text("VERSION = 2\n", encoding="utf-8") + (source / "untracked.py").write_text("UNTRACKED = True\n", encoding="utf-8") + + metadata = BENCHMARK.copy_git_revision_to_dir( + source, + destination, + revision, + timeout=30, + excluded_prefixes=("benchmarks/semantic-pairs-v1/",), + ) + + self.assertEqual((destination / "tracked.py").read_text(), "VERSION = 1\n") + self.assertFalse((destination / "untracked.py").exists()) + self.assertFalse( + (destination / "benchmarks" / "semantic-pairs-v1").exists() + ) + self.assertEqual(metadata["revision"], revision) + self.assertRegex(metadata["tree"], r"^[0-9a-f]{40}$") + self.assertIn("tracked.py", metadata["source_dirty_status_short"]) + self.assertFalse((destination / ".git").exists()) + self.assertEqual( + metadata["excluded_prefixes"], ["benchmarks/semantic-pairs-v1/"] + ) + + def test_pair_classification_scores_explicit_positive_and_negative_judgments( + self, + ) -> None: + judgments = [ + { + "source": "fixture.alpha", + "target": "fixture.beta", + "expected": True, + "category": "near_clone", + }, + { + "source": "fixture.alpha", + "target": "fixture.decoy", + "expected": False, + "category": "lexical_hard_negative", + }, + { + "source": "fixture.gamma", + "target": "fixture.delta", + "expected": True, + "category": "near_clone", + }, + { + "source": "fixture.gamma", + "target": "fixture.decoy", + "expected": False, + "category": "unrelated_negative", + }, + ] + observed = [ + {"source": "fixture.beta", "target": "fixture.alpha", "score": 0.98}, + {"source": "fixture.alpha", "target": "fixture.decoy", "score": 0.96}, + {"source": "background.one", "target": "background.two", "score": 0.97}, + ] + + result = BENCHMARK.score_pair_classification(observed, judgments) + + self.assertEqual(result["confusion"], {"tp": 1, "fp": 1, "fn": 1, "tn": 1}) + self.assertEqual(result["precision"], 0.5) + self.assertEqual(result["recall"], 0.5) + self.assertEqual(result["f1"], 0.5) + self.assertEqual(result["false_positive_rate"], 0.5) + self.assertEqual(result["unjudged_observed_count"], 1) + self.assertEqual(result["unjudged_observed"][0]["source"], "background.one") + self.assertEqual(result["categories"]["near_clone"]["tp"], 1) + self.assertEqual(result["categories"]["near_clone"]["fn"], 1) + self.assertEqual(result["categories"]["lexical_hard_negative"]["fp"], 1) + + def test_pair_classification_rejects_duplicate_or_conflicting_judgments( + self, + ) -> None: + duplicate = [ + {"source": "fixture.a", "target": "fixture.b", "expected": True}, + {"source": "fixture.b", "target": "fixture.a", "expected": True}, + ] + conflicting = [ + {"source": "fixture.a", "target": "fixture.b", "expected": True}, + {"source": "fixture.b", "target": "fixture.a", "expected": False}, + ] + + with self.assertRaisesRegex(ValueError, "duplicate pair judgment"): + BENCHMARK.score_pair_classification([], duplicate) + with self.assertRaisesRegex(ValueError, "duplicate pair judgment"): + BENCHMARK.score_pair_classification([], conflicting) + + def test_pair_classification_reports_undefined_denominators_as_null(self) -> None: + result = BENCHMARK.score_pair_classification( + [], + [ + { + "source": "fixture.a", + "target": "fixture.b", + "expected": False, + "category": "negative", + } + ], + ) + + self.assertIsNone(result["precision"]) + self.assertIsNone(result["recall"]) + self.assertIsNone(result["f1"]) + self.assertEqual(result["false_positive_rate"], 0.0) + + def test_similarity_quality_fixture_has_versioned_pair_judgments_and_hashes( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + fixture = BENCHMARK.create_similarity_quality_repo(Path(tmpdir)) + source = (Path(tmpdir) / "cbmq_similarity.go").read_text() + + self.assertEqual(fixture["capability"], "similarity") + self.assertEqual(fixture["relationship"], "SIMILAR_TO") + self.assertEqual(fixture["task_set_version"], "semantic-pairs-v1") + self.assertRegex(fixture["task_set_sha256"], r"^[0-9a-f]{64}$") + self.assertEqual(len(fixture["source_sha256"]), 1) + self.assertTrue(any(item["expected"] for item in fixture["judgments"])) + self.assertTrue(any(not item["expected"] for item in fixture["judgments"])) + self.assertIn("cbmqValidateUser", source) + self.assertIn("cbmqValidateOrder", source) + self.assertIn("cbmqValidateProfileDecoy", source) + + def test_semantic_edges_quality_fixture_is_distinct_from_similarity_task( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + fixture = BENCHMARK.create_semantic_edges_quality_repo(Path(tmpdir)) + source = (Path(tmpdir) / "cbmq_records.py").read_text() + + self.assertEqual(fixture["capability"], "semantic_edges") + self.assertEqual(fixture["relationship"], "SEMANTICALLY_RELATED") + self.assertEqual(fixture["task_set_version"], "semantic-pairs-v1") + self.assertTrue(any(item["expected"] for item in fixture["judgments"])) + self.assertTrue(any(not item["expected"] for item in fixture["judgments"])) + self.assertIn("cbmq_normalize_user_record", source) + self.assertIn("cbmq_normalize_account_record", source) + self.assertIn("cbmq_archive_record_decoy", source) + + def test_pair_quality_mutation_replaces_exact_source_and_changes_judgments( + self, + ) -> None: + for factory, expected_added, expected_removed in ( + ( + BENCHMARK.create_similarity_quality_repo, + ("cbmqValidateProfileDecoy", "cbmqValidateUser"), + ("cbmqValidateOrder", "cbmqValidateUser"), + ), + ( + BENCHMARK.create_semantic_edges_quality_repo, + ("cbmq_archive_record_decoy", "cbmq_normalize_user_record"), + ("cbmq_normalize_account_record", "cbmq_normalize_user_record"), + ), + ): + with ( + self.subTest(factory=factory.__name__), + tempfile.TemporaryDirectory() as tmpdir, + ): + repo = Path(tmpdir) + fixture = factory(repo) + mutation = BENCHMARK.apply_pair_quality_mutation(repo, fixture) + + self.assertEqual( + mutation["changed_paths"], [fixture["source_paths"][0]] + ) + self.assertNotEqual(mutation["before_sha256"], mutation["after_sha256"]) + post_expected = { + BENCHMARK.canonical_pair(item["source"], item["target"]): item[ + "expected" + ] + for item in mutation["post_judgments"] + } + self.assertTrue( + post_expected[BENCHMARK.canonical_pair(*expected_added)] + ) + self.assertFalse( + post_expected[BENCHMARK.canonical_pair(*expected_removed)] + ) + + def test_relation_quality_oracle_scores_raw_query_rows_and_response_cost( + self, + ) -> None: + calls = [] + original = BENCHMARK.run_tool_call_for_transport + + def fake_call(*args, **kwargs): + calls.append((args[3], args[4])) + return { + "elapsed_ms": 4.25, + "response_bytes": 211, + "response_token_estimate": 53, + "response": { + "columns": [ + "a.name", + "b.name", + "r.jaccard", + "a.file_path", + "b.file_path", + ], + "rows": [ + [ + "cbmqValidateOrder", + "cbmqValidateUser", + "0.984", + "cbmq_similarity.go", + "cbmq_similarity.go", + ] + ], + }, + } + + class Args: + timeout = 10 + include_logs = False + + fixture = { + "relationship": "SIMILAR_TO", + "score_property": "jaccard", + "query_name_marker": "cbmq", + "judgments": [ + { + "source": "cbmqValidateUser", + "target": "cbmqValidateOrder", + "expected": True, + "category": "structural_near_clone", + }, + { + "source": "cbmqValidateUser", + "target": "cbmqValidateProfileDecoy", + "expected": False, + "category": "lexical_hard_negative", + }, + ], + } + BENCHMARK.run_tool_call_for_transport = fake_call + try: + result = BENCHMARK.run_relation_quality_oracles( + "cli", Path("cbm"), {}, "fixture", fixture, Args() + ) + finally: + BENCHMARK.run_tool_call_for_transport = original + + self.assertEqual(calls[0][0], "query_graph") + self.assertIn("SIMILAR_TO", calls[0][1]["query"]) + self.assertEqual(calls[0][1]["format"], "json") + self.assertEqual( + result["pair_classification"]["confusion"], + { + "tp": 1, + "fp": 0, + "fn": 0, + "tn": 1, + }, + ) + self.assertTrue(result["passed"]) + self.assertEqual(result["response_quality"]["response_bytes"], 211) + self.assertEqual(result["observed_pairs"][0]["score"], 0.984) + + def test_pair_oracle_equality_is_order_independent_but_score_sensitive( + self, + ) -> None: + incremental = { + "observed_pairs": [ + {"source": "b", "target": "a", "score": 0.87}, + {"source": "c", "target": "a", "score": 0.91}, + ] + } + fresh = { + "observed_pairs": [ + {"source": "a", "target": "c", "score": 0.91}, + {"source": "a", "target": "b", "score": 0.87}, + ] + } + + equal = BENCHMARK.compare_pair_oracle_outputs(incremental, fresh) + self.assertTrue(equal["passed"]) + + fresh["observed_pairs"][0]["score"] = 0.90 + unequal = BENCHMARK.compare_pair_oracle_outputs(incremental, fresh) + self.assertFalse(unequal["passed"]) + self.assertEqual(len(unequal["incremental_only"]), 1) + self.assertEqual(len(unequal["fresh_only"]), 1) + + def test_pair_incremental_policy_observes_candidate_default_without_assuming_policy( + self, + ) -> None: + stale_index = {"publish_kind": "incremental_exact"} + stale_oracles = { + "passed": False, + "edge_query": { + "response": { + "warnings": [ + "semantic_edges derived view is stale; query_graph semantic edges may be stale." + ] + } + }, + } + stale = BENCHMARK.evaluate_pair_incremental_policy( + {}, stale_index, stale_oracles, {"equal": False}, {"passed": False} + ) + self.assertEqual(stale["policy"], "candidate_default") + self.assertEqual(stale["policy_source"], "candidate_default") + self.assertEqual(stale["observed_behavior"], "deferred_with_warning") + self.assertFalse(stale["immediate_freshness_expected"]) + self.assertTrue(stale["policy_conformance_met"]) + + observed_eager = BENCHMARK.evaluate_pair_incremental_policy( + {}, + stale_index, + {"passed": True, "edge_query": {"response": {}}}, + {"equal": False}, + {"passed": True}, + ) + self.assertEqual(observed_eager["policy"], "candidate_default") + self.assertEqual( + observed_eager["observed_behavior"], "immediate_pair_freshness" + ) + self.assertTrue(observed_eager["immediate_freshness_expected"]) + self.assertTrue(observed_eager["pair_freshness_met"]) + self.assertFalse(observed_eager["immediate_freshness_met"]) + self.assertTrue(observed_eager["policy_conformance_met"]) + + unreported_stale = BENCHMARK.evaluate_pair_incremental_policy( + {}, + stale_index, + {"passed": False, "edge_query": {"response": {}}}, + {"equal": False}, + {"passed": False}, + ) + self.assertEqual(unreported_stale["observed_behavior"], "unreported_stale") + self.assertFalse(unreported_stale["policy_conformance_met"]) + + at_publish = BENCHMARK.evaluate_pair_incremental_policy( + {"incremental_derived_results_refresh": "at_publish"}, + stale_index, + {"passed": True, "edge_query": {"response": {}}}, + {"equal": True}, + {"passed": True}, + ) + self.assertEqual(at_publish["policy_source"], "explicit_override") + self.assertEqual(at_publish["observed_behavior"], "immediate_full_freshness") + self.assertTrue(at_publish["immediate_freshness_expected"]) + self.assertTrue(at_publish["immediate_freshness_met"]) + self.assertTrue(at_publish["policy_conformance_met"]) + + def test_search_projection_observation_separates_identity_and_property_fields( + self, + ) -> None: + data = { + "results": [ + { + "qualified_name": "fixture.Func0000_00", + "label": "Function", + "file_path": "pkg/file_0000.go", + "source": "project", + "complexity": 1, + "fp": "opaque", + } + ] + } + + observation = BENCHMARK.build_search_projection_observation( + "compact_fields", data, 400, 2.5, True + ) + + self.assertEqual(observation["qualified_names"], ["fixture.Func0000_00"]) + self.assertEqual(observation["property_fields"], ["complexity", "fp"]) + self.assertEqual(observation["internal_fields"], ["fp"]) + self.assertFalse(observation["passed"]) + self.assertEqual( + observation["response_bytes"], len(BENCHMARK.canonical_response_bytes(data)) + ) + + def test_parse_list_project_counts_requires_strictly_increasing_positive_values( + self, + ) -> None: + self.assertEqual(BENCHMARK.parse_list_project_counts("1,16,64"), [1, 16, 64]) + for invalid in ("", "0,1", "1,1", "16,1", "1,two"): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + BENCHMARK.parse_list_project_counts(invalid) + + def test_clone_list_project_db_rekeys_rows_without_mutating_seed(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + seed = Path(tmpdir) / "seed.db" + clone = Path(tmpdir) / "clone.db" + with closing(sqlite3.connect(seed)) as con, con: + con.executescript( + "CREATE TABLE projects(name TEXT PRIMARY KEY, root_path TEXT);" + "CREATE TABLE nodes(id INTEGER PRIMARY KEY, project TEXT);" + "CREATE TABLE edges(id INTEGER PRIMARY KEY, project TEXT);" + "INSERT INTO projects VALUES('seed','/seed');" + "INSERT INTO nodes VALUES(1,'seed');" + "INSERT INTO edges VALUES(1,'seed');" + ) + + BENCHMARK.clone_list_project_db(seed, clone, "clone", "/clone") + + with closing(sqlite3.connect(seed)) as con: + self.assertEqual( + con.execute("SELECT name FROM projects").fetchone()[0], "seed" + ) + with closing(sqlite3.connect(clone)) as con: + self.assertEqual( + con.execute("SELECT name, root_path FROM projects").fetchone(), + ("clone", "/clone"), + ) + self.assertEqual( + con.execute("SELECT project FROM nodes").fetchone()[0], "clone" + ) + self.assertEqual( + con.execute("SELECT project FROM edges").fetchone()[0], "clone" + ) + + def test_list_project_fixture_budget_enforces_cap_and_free_space_reserve( + self, + ) -> None: + mib = 1024 * 1024 + budget = BENCHMARK.list_project_fixture_budget( + seed_bytes=mib, + maximum_projects=64, + maximum_fixture_mb=64, + disk_free_bytes=4 * 1024 * mib, + ) + self.assertTrue(budget["passed"]) + self.assertEqual(budget["projected_fixture_bytes"], 64 * mib) + self.assertEqual(budget["reserved_free_bytes"], 2 * 1024 * mib) + + capped = BENCHMARK.list_project_fixture_budget( + seed_bytes=mib, + maximum_projects=64, + maximum_fixture_mb=63, + disk_free_bytes=4 * 1024 * mib, + ) + self.assertFalse(capped["passed"]) + self.assertEqual(capped["reason"], "projected fixture exceeds configured cap") + + reserve = BENCHMARK.list_project_fixture_budget( + seed_bytes=3 * 1024 * mib, + maximum_projects=1, + maximum_fixture_mb=4096, + disk_free_bytes=4 * 1024 * mib, + ) + self.assertFalse(reserve["passed"]) + self.assertEqual( + reserve["reason"], "projected fixture violates free-space reserve" + ) + + def test_mcp_client_exit_reaps_process_streams_and_reader_threads(self) -> None: + class FakeStream: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + class FakeProcess: + def __init__(self) -> None: + self.stdin = FakeStream() + self.stdout = FakeStream() + self.stderr = FakeStream() + self.wait_calls = 0 + + def wait(self, timeout: int) -> int: + self.wait_calls += 1 + return 0 + + class FakeThread: + def __init__(self) -> None: + self.join_calls = 0 + + def join(self, timeout: int) -> None: + self.join_calls += 1 + + def is_alive(self) -> bool: + return False + + client = BENCHMARK.McpClient(Path("cbm"), {}, 10) + process = FakeProcess() + stdout_thread = FakeThread() + stderr_thread = FakeThread() + client.proc = process + client.stdout_thread = stdout_thread + client.stderr_thread = stderr_thread + + client.__exit__(None, None, None) + + self.assertTrue(process.stdin.closed) + self.assertTrue(process.stdout.closed) + self.assertTrue(process.stderr.closed) + self.assertEqual(process.wait_calls, 1) + self.assertEqual(stdout_thread.join_calls, 1) + self.assertEqual(stderr_thread.join_calls, 1) + self.assertIsNone(client.proc) + + def test_mcp_client_call_tool_names_empty_non_json_response(self) -> None: + client = BENCHMARK.McpClient(Path("cbm"), {}, 10) + with mock.patch.object( + client, + "call_tool_text", + return_value=("", "worker diagnostics", 123, 4.5), + ): + with self.assertRaisesRegex( + RuntimeError, + r"MCP tool index_repository returned non-JSON text: ''", + ): + client.call_tool("index_repository", {"repo_path": "/tmp/repo"}) + + def test_rank_quality_fixture_separates_graph_signal_from_lexical_order( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + metadata = BENCHMARK.create_rank_quality_repo(Path(tmpdir)) + core = (Path(tmpdir) / "order_core.py").read_text() + stubs = (Path(tmpdir) / "order_stubs.py").read_text() + callers = sorted(Path(tmpdir).glob("caller_*.py")) + caller_sources = [path.read_text() for path in callers] + + self.assertEqual(metadata["capability"], "rank") + self.assertEqual(metadata["relevant_symbol"], "zz_order_core") + self.assertEqual(len(metadata["lexical_decoys"]), 8) + self.assertIn("def zz_order_core", core) + self.assertIn("def aa_order_stub", stubs) + self.assertEqual(len(callers), 8) + self.assertTrue(all("zz_order_core" in source for source in caller_sources)) + + def test_dependency_quality_fixture_has_local_resolvable_source(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + metadata = BENCHMARK.create_dependency_quality_repo(Path(tmpdir)) + manifest = json.loads((Path(tmpdir) / "package.json").read_text()) + app_source = (Path(tmpdir) / "src" / "app.js").read_text() + dep_source = ( + Path(tmpdir) / "node_modules" / "cbmbenchdep" / "index.js" + ).read_text() + + self.assertEqual(metadata["capability"], "dependencies") + self.assertEqual(manifest["dependencies"], {"cbmbenchdep": "1.0.0"}) + self.assertIn("canonicalDependencyAPI", app_source) + self.assertIn("canonicalDependencyAPI", dep_source) + self.assertEqual(metadata["relevant_symbol"], "canonicalDependencyAPI") + + def test_git_history_quality_fixture_has_four_coupled_commits(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + metadata = BENCHMARK.create_git_history_quality_repo(root) + commit_count = subprocess.run( + ["git", "rev-list", "--count", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + self.assertEqual(metadata["capability"], "git_history") + self.assertEqual(metadata["expected_co_changes"], 4) + self.assertEqual(metadata["coupled_paths"], ["alpha.py", "beta.py"]) + self.assertEqual(commit_count, "4") + + def test_http_links_quality_fixture_has_client_and_route_marker(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + metadata = BENCHMARK.create_http_links_quality_repo(root) + client = (root / "client" / "service.py").read_text() + routes = (root / "server" / "Routes.kt").read_text() + + self.assertEqual(metadata["capability"], "http_links") + self.assertEqual(metadata["route_path"], "/api/cbmbench-orders/42") + self.assertIn("requests.get", client) + self.assertIn(metadata["route_path"], client) + self.assertIn('get("/api/cbmbench-orders/{order_id}")', routes) + + def test_rank_quality_oracle_uses_central_symbol_as_graded_judgment(self) -> None: + calls = [] + original = BENCHMARK.run_tool_call_for_transport + + def fake_call(*args, **kwargs): + calls.append((args[3], args[4])) + return { + "response": { + "results": [ + {"name": "aa_order_stub"}, + {"name": "zz_order_core"}, + ] + } + } + + class Args: + timeout = 10 + include_logs = False + + BENCHMARK.run_tool_call_for_transport = fake_call + try: + result = BENCHMARK.run_rank_quality_oracles( + "cli", Path("cbm"), {}, "fixture", Args() + ) + finally: + BENCHMARK.run_tool_call_for_transport = original + + self.assertEqual(calls[0][0], "search_graph") + self.assertEqual(calls[0][1]["name_pattern"], "order") + quality = result["central_order_search"]["quality"] + self.assertEqual(quality["expected_substring"], "zz_order_core") + self.assertEqual(quality["rank"], 2) + self.assertEqual(quality["reciprocal_rank"], 0.5) + self.assertIsNotNone(quality["ndcg_at_5"]) + + def test_dependency_quality_oracle_requires_dependency_provenance(self) -> None: + calls = [] + original = BENCHMARK.run_tool_call_for_transport + + def fake_call(*args, **kwargs): + calls.append((args[3], args[4])) + return { + "response": { + "results": [ + { + "name": "canonicalDependencyAPI", + "source": "dependency", + "package": "cbmbenchdep", + "read_only": True, + } + ] + } + } + + class Args: + timeout = 10 + include_logs = False + + BENCHMARK.run_tool_call_for_transport = fake_call + try: + result = BENCHMARK.run_dependency_quality_oracles( + "cli", Path("cbm"), {}, "fixture", Args() + ) + finally: + BENCHMARK.run_tool_call_for_transport = original + + self.assertEqual(calls[0][0], "search_graph") + self.assertTrue(calls[0][1]["include_dependencies"]) + self.assertEqual(calls[0][1]["name_pattern"], "canonicalDependencyAPI") + quality = result["dependency_api_search"]["quality"] + self.assertTrue(quality["passed"]) + self.assertEqual(quality["rank"], 1) + self.assertEqual( + quality["required_substrings"], + [ + '"source":"dependency"', + '"package":"cbmbenchdep"', + '"read_only":true', + ], + ) + + def test_git_history_quality_oracle_queries_existing_edge_schema(self) -> None: + calls = [] + original = BENCHMARK.run_tool_call_for_transport + + def fake_call(*args, **kwargs): + calls.append((args[3], args[4])) + return {"response": {"rows": [["alpha.py", "beta.py", "4", "1.00"]]}} + + class Args: + timeout = 10 + include_logs = False + + BENCHMARK.run_tool_call_for_transport = fake_call + try: + result = BENCHMARK.run_git_history_quality_oracles( + "cli", Path("cbm"), {}, "fixture", Args() + ) + finally: + BENCHMARK.run_tool_call_for_transport = original + + self.assertEqual(calls[0][0], "query_graph") + self.assertIn("FILE_CHANGES_WITH", calls[0][1]["query"]) + self.assertTrue(result["file_change_coupling"]["quality"]["passed"]) + self.assertEqual( + result["file_change_coupling"]["quality"]["required_substrings"], + ["beta.py", '"4"', '"1.00"'], + ) + + def test_http_links_quality_oracle_queries_existing_edge_schema(self) -> None: + calls = [] + original = BENCHMARK.run_tool_call_for_transport + + def fake_call(*args, **kwargs): + calls.append((args[3], args[4])) + return { + "response": { + "rows": [ + [ + "fetch_order", + "configureRouting", + "/api/cbmbench-orders/42", + "0.875", + ] + ] + } + } + + class Args: + timeout = 10 + include_logs = False + + BENCHMARK.run_tool_call_for_transport = fake_call + try: + result = BENCHMARK.run_http_links_quality_oracles( + "cli", Path("cbm"), {}, "fixture", Args() + ) + finally: + BENCHMARK.run_tool_call_for_transport = original + + self.assertEqual(calls[0][0], "query_graph") + self.assertIn("HTTP_CALLS", calls[0][1]["query"]) + self.assertIn("b.name = 'configureRouting'", calls[0][1]["query"]) + self.assertTrue(result["http_call_link"]["quality"]["passed"]) + self.assertEqual( + result["http_call_link"]["quality"]["required_substrings"], + ["fetch_order", "configureRouting"], + ) + + def test_reciprocal_rank_uses_full_bounded_result_beyond_ndcg_cutoff(self) -> None: + ranked = [{"name": f"decoy_{index}"} for index in range(8)] + ranked.append({"name": "relevant"}) + + result = BENCHMARK.score_ranked_relevance( + ranked, + [{"expected_substring": "relevant", "relevance": 3}], + cutoff=5, + ) + + self.assertEqual(result["first_relevant_rank"], 9) + self.assertAlmostEqual(result["reciprocal_rank"], 1 / 9) + self.assertFalse(result["hit_at_5"]) + self.assertEqual(result["ndcg_at_5"], 0.0) + self.assertEqual(len(result["matched_relevance"]), 5) + + def test_frontier_fixture_counts_dependents_and_mutates_one_definition_file( + self, + ) -> None: + cases = { + "go_inbound_frontier": ("go", "leaf.go", "LeafExtra"), + "python_inbound_frontier": ("python", "leaf.py", "leaf_extra"), + "c_header_inbound_frontier": ("c_header", "shared.h", "shared_extra"), + "cpp_inbound_frontier": ("cpp", "shared.hpp", "shared_extra"), + "cuda_inbound_frontier": ("cuda", "shared.cuh", "shared_extra"), + "javascript_inbound_frontier": ("javascript", "leaf.js", "leafExtra"), + "typescript_inbound_frontier": ("typescript", "leaf.ts", "leafExtra"), + "tsx_inbound_frontier": ("tsx", "leaf.tsx", "leafExtra"), + "php_inbound_frontier": ("php", "Leaf.php", "leaf_extra"), + "csharp_inbound_frontier": ("csharp", "Leaf.cs", "Extra"), + "java_inbound_frontier": ("java", "Leaf.java", "extra"), + "kotlin_inbound_frontier": ("kotlin", "Leaf.kt", "leafExtra"), + "rust_inbound_frontier": ("rust", "leaf.rs", "leaf_extra"), + } + for scenario, (language, changed_path, marker) in cases.items(): + with ( + self.subTest(scenario=scenario), + tempfile.TemporaryDirectory() as tmpdir, + ): + repo = Path(tmpdir) + metadata = BENCHMARK.create_inbound_frontier_repo(repo, language, 7) + changed = BENCHMARK.mutate_inbound_frontier_repo(repo, language) + + self.assertEqual(metadata["language"], language) + self.assertEqual(metadata["requested_inbound_dependents"], 7) + resolver_language = "c" if language == "c_header" else language + if resolver_language in BENCHMARK.SCOPED_EXACT_FRONTIER_LANGUAGES: + self.assertEqual(metadata["incremental_contract"], "exact_frontier") + self.assertEqual(metadata["expected_minimum_affected_files"], 8) + else: + self.assertEqual( + metadata["incremental_contract"], "safe_full_rebuild" + ) + self.assertEqual(metadata["expected_publish_kind"], "full") + self.assertEqual(metadata["expected_reason"], "scoped_lsp_gap") + self.assertEqual(changed, [changed_path]) + self.assertIn(marker, (repo / changed_path).read_text(encoding="utf-8")) + for index in range(7): + self.assertTrue( + (repo / metadata["dependent_paths"][index]).is_file() + ) + + def test_frontier_catalog_matches_cross_file_resolver_languages(self) -> None: + fixture_languages = { + "c" if language == "c_header" else language + for language in BENCHMARK.MATRIX_FRONTIER_SCENARIOS.values() + } + self.assertEqual( + fixture_languages, set(BENCHMARK.CROSS_FILE_RESOLVER_LANGUAGES) + ) + + def test_frontier_fixture_rejects_nonpositive_dependent_count(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaisesRegex(ValueError, "frontier files must be positive"): + BENCHMARK.create_inbound_frontier_repo(Path(tmpdir), "go", 0) + + def test_frontier_gate_rejects_fixture_that_did_not_expand(self) -> None: + metadata = {"expected_minimum_affected_files": 8} + incremental = {"response": {"exact_delta": {"affected_paths": 1}}} + + gate = BENCHMARK.frontier_coverage_gate(metadata, incremental) + + self.assertFalse(gate["passed"]) + self.assertEqual(gate["expected_minimum_affected_files"], 8) + self.assertEqual(gate["observed_affected_files"], 1) + self.assertEqual( + gate["reason"], "observed frontier is smaller than the fixture contract" + ) + + def test_frontier_gate_is_not_applicable_to_nonfrontier_scenarios(self) -> None: + gate = BENCHMARK.frontier_coverage_gate({}, {"response": {}}) + + self.assertTrue(gate["passed"]) + self.assertFalse(gate["applicable"]) + + def test_frontier_gate_accepts_declared_scoped_lsp_full_rebuild(self) -> None: + metadata = { + "expected_publish_kind": "full", + "expected_reason": "scoped_lsp_gap", + } + incremental = {"publish_kind": "full", "exact_reason": "scoped_lsp_gap"} + + gate = BENCHMARK.frontier_coverage_gate(metadata, incremental) + + self.assertTrue(gate["passed"]) + self.assertEqual(gate["contract"], "safe_full_rebuild") + + def test_frontier_gate_accepts_explicit_configured_cap_fallback(self) -> None: + metadata = {"expected_minimum_affected_files": 17} + incremental = { + "publish_kind": "incremental_containment", + "exact_reason": "frontier_too_large", + "response": { + "exact_delta": { + "affected_paths": 16, + "affected_paths_limit": 16, + "affected_paths_truncated": True, + } + }, + } + + gate = BENCHMARK.frontier_coverage_gate(metadata, incremental, exact_cap=16) + + self.assertTrue(gate["passed"]) + self.assertEqual(gate["contract"], "configured_cap_fallback") + self.assertEqual(gate["expected_minimum_affected_files"], 17) + + def test_frontier_gate_rejects_cap_fallback_without_truncation_evidence( + self, + ) -> None: + metadata = {"expected_minimum_affected_files": 17} + incremental = { + "publish_kind": "full", + "exact_reason": "frontier_too_large", + "response": {"exact_delta": {"affected_paths_truncated": False}}, + } + + gate = BENCHMARK.frontier_coverage_gate(metadata, incremental, exact_cap=16) + + self.assertFalse(gate["passed"]) + self.assertIn("truncation", gate["reason"]) + + def test_minimal_indexing_profile_disables_every_optional_cost_center(self) -> None: + overrides = BENCHMARK.resolve_config_overrides("minimal_indexing", []) + self.assertEqual( + overrides, + { + "auto_index_deps": "false", + "githistory_enabled": "false", + "httplinks_enabled": "false", + "rank_enabled": "false", + "semantic_edges_enabled": "false", + "similarity_enabled": "false", + }, + ) + self.assertEqual( + BENCHMARK.resolve_config_overrides("optional_graph_disabled", []), + overrides, + "retained plans must keep loading the removed duplicate profile", + ) + + def test_automatic_dependency_source_profiles_change_only_dependency_indexing( + self, + ) -> None: + disabled = BENCHMARK.resolve_config_overrides( + "automatic_dependency_source_indexing_disabled", [] + ) + enabled = BENCHMARK.resolve_config_overrides( + "automatic_dependency_source_indexing_enabled", [] + ) + self.assertEqual( + disabled, + BENCHMARK.PRODUCT_DEFAULT_GRAPH_CAPABILITIES, + ) + self.assertEqual(enabled, {**disabled, "auto_index_deps": "true"}) + self.assertEqual( + BENCHMARK.resolve_config_overrides("candidate_native_configuration", []), + {}, + ) + + def test_incremental_derived_results_refresh_at_publish_profile_changes_only_policy( + self, + ) -> None: + self.assertEqual( + BENCHMARK.resolve_config_overrides( + "incremental_derived_results_refresh_at_publish", [] + ), + { + **BENCHMARK.PRODUCT_DEFAULT_GRAPH_CAPABILITIES, + "incremental_derived_results_refresh": "at_publish", + }, + ) + + def test_single_capability_ablation_profiles_change_exactly_one_group(self) -> None: + changed_keys = { + "rank_disabled": "rank_enabled", + "similarity_disabled": "similarity_enabled", + "semantic_edges_disabled": "semantic_edges_enabled", + "git_history_disabled": "githistory_enabled", + "http_links_disabled": "httplinks_enabled", + } + baseline = BENCHMARK.PRODUCT_DEFAULT_GRAPH_CAPABILITIES + for profile, changed_key in changed_keys.items(): + resolved = BENCHMARK.resolve_config_overrides(profile, []) + differences = { + key for key, value in resolved.items() if baseline.get(key) != value + } + self.assertEqual(differences, {changed_key}) + self.assertEqual(resolved[changed_key], "false") + + def test_index_mode_metadata_marks_fast_only_capability_gaps(self) -> None: + self.assertEqual( + BENCHMARK.index_mode_capability_applicability("fast"), + { + "rank": {"applicable": True, "reason": "available in fast mode"}, + "similarity": { + "applicable": False, + "reason": "SIMILAR_TO generation requires full or moderate mode", + }, + "semantic_edges": { + "applicable": False, + "reason": "SEMANTICALLY_RELATED generation requires full or moderate mode", + }, + "git_history": {"applicable": True, "reason": "available in fast mode"}, + "http_links": {"applicable": True, "reason": "available in fast mode"}, + "dependencies": { + "applicable": True, + "reason": "available in fast mode", + }, + }, + ) + self.assertTrue( + all( + value["applicable"] + for value in BENCHMARK.index_mode_capability_applicability( + "full" + ).values() + ) + ) + + def test_index_tool_arguments_preserve_requested_mode(self) -> None: + self.assertEqual( + BENCHMARK.index_tool_arguments(Path("/tmp/repo"), "moderate"), + {"repo_path": "/tmp/repo", "mode": "moderate"}, + ) + with self.assertRaisesRegex(ValueError, "unsupported index mode"): + BENCHMARK.index_tool_arguments(Path("/tmp/repo"), "turbo") + + def test_graded_relevance_scores_mrr_hits_and_ndcg(self) -> None: + ranked = [ + {"name": "related_helper"}, + {"name": "canonical_entry_point"}, + {"name": "unrelated"}, + ] + judgments = [ + {"expected_substring": "canonical_entry_point", "relevance": 3}, + {"expected_substring": "related_helper", "relevance": 1}, + ] + + score = BENCHMARK.score_ranked_relevance(ranked, judgments, cutoff=5) + + expected_dcg = 1.0 + 7.0 / BENCHMARK.math.log2(3) + expected_idcg = 7.0 + 1.0 / BENCHMARK.math.log2(3) + self.assertEqual(score["first_relevant_rank"], 1) + self.assertEqual(score["reciprocal_rank"], 1.0) + self.assertTrue(score["hit_at_1"]) + self.assertTrue(score["hit_at_5"]) + self.assertAlmostEqual(score["ndcg_at_5"], expected_dcg / expected_idcg) + self.assertEqual(score["matched_relevance"], [1, 3, 0]) + + def test_graded_relevance_missing_evidence_scores_zero(self) -> None: + score = BENCHMARK.score_ranked_relevance( + [{"name": "unrelated"}], + [{"expected_substring": "required", "relevance": 3}], + cutoff=5, + ) + + self.assertIsNone(score["first_relevant_rank"]) + self.assertEqual(score["reciprocal_rank"], 0.0) + self.assertEqual(score["ndcg_at_5"], 0.0) + + def test_graded_relevance_requires_provenance_on_the_same_result(self) -> None: + ranked = [ + { + "name": "canonicalDependencyAPI", + "source": "project", + "package": "cbmbenchdep", + "read_only": False, + }, + { + "name": "canonicalDependencyAPI", + "source": "dependency", + "package": "cbmbenchdep", + "read_only": True, + }, + ] + judgments = [ + { + "expected_substring": "canonicalDependencyAPI", + "required_substrings": [ + '"source":"dependency"', + '"package":"cbmbenchdep"', + '"read_only":true', + ], + "relevance": 3, + } + ] + + score = BENCHMARK.score_ranked_relevance(ranked, judgments, cutoff=5) + + self.assertEqual(score["first_relevant_rank"], 2) + self.assertEqual(score["matched_relevance"], [0, 3]) + + def test_quality_oracle_accepts_graded_relevance_judgments(self) -> None: + oracles = { + "ranked": { + "response": { + "results": [ + {"name": "related_helper"}, + {"name": "canonical_entry_point"}, + ] + } + } + } + expectations = { + "ranked": { + "criterion": "architectural entry points rank ahead of unrelated symbols", + "judgments": [ + {"expected_substring": "canonical_entry_point", "relevance": 3}, + {"expected_substring": "related_helper", "relevance": 1}, + ], + "cutoff": 5, + } + } + + summary = BENCHMARK.score_quality_oracles(oracles, expectations) + + self.assertTrue(summary["passed"]) + self.assertIsNotNone(summary["mean_ndcg_at_5"]) + self.assertEqual(oracles["ranked"]["quality"]["relevance_judgments"], 2) + self.assertEqual(oracles["ranked"]["quality"]["rank"], 1) + self.assertIn("ndcg_at_5", oracles["ranked"]["quality"]) + + def test_surface_parity_separates_pre_reveal_discovery_from_dispatch(self) -> None: + schema_a = {"type": "object", "properties": {"query": {"type": "string"}}} + schema_b = {"type": "object", "properties": {"path": {"type": "string"}}} + classic = [ + {"name": "search_graph", "inputSchema": schema_a}, + {"name": "index_repository", "inputSchema": schema_b}, + {"name": "get_code_snippet", "inputSchema": schema_b}, + ] + pre = [ + {"name": "search_graph", "inputSchema": schema_a}, + { + "name": "get_code", + "inputSchema": { + "type": "object", + "properties": {"qualified_name": {"type": "string"}}, + }, + }, + {"name": "_hidden_tools", "inputSchema": {"type": "object"}}, + ] + post = [ + *pre, + {"name": "index_repository", "inputSchema": schema_b}, + {"name": "get_code_snippet", "inputSchema": schema_b}, + ] + + comparison = BENCHMARK.compare_mcp_tool_surfaces( + pre, + post, + classic, + pre_dispatch={ + "search_graph": True, + "index_repository": True, + "get_code_snippet": True, + }, + list_changed_observed=True, + ) + + self.assertEqual(comparison["pre_reveal"]["advertised_classic_tools"], "1/3") + self.assertEqual( + comparison["pre_reveal"]["dispatch_recognized_classic_tools"], "3/3" + ) + self.assertEqual( + comparison["pre_reveal"]["intentionally_hidden_classic_tools"], + ["get_code_snippet", "index_repository"], + ) + self.assertTrue(comparison["pre_reveal"]["classic_dispatch_parity"]) + self.assertFalse(comparison["pre_reveal"]["get_code_alias"]["schema_equal"]) + self.assertFalse( + comparison["pre_reveal"]["get_code_alias"]["validation_shape_equal"] + ) + self.assertFalse( + comparison["pre_reveal"]["get_code_alias"]["property_names_equal"] + ) + self.assertEqual( + comparison["pre_reveal"]["get_code_alias"]["classic_only_properties"], + ["path"], + ) + self.assertTrue(comparison["post_reveal"]["classic_name_parity"]) + self.assertTrue(comparison["post_reveal"]["classic_schema_parity"]) + self.assertTrue(comparison["post_reveal"]["classic_contract_parity"]) + self.assertTrue(comparison["post_reveal"]["tools_list_changed_observed"]) + capabilities = { + item["capability"]: item for item in comparison["capability_parity"] + } + self.assertTrue( + capabilities["structural_search"]["streamlined_pre_reveal_callable"] + ) + self.assertTrue( + capabilities["source_retrieval"]["streamlined_pre_reveal_callable"] + ) + self.assertIn( + "input/output schemas", + comparison["comparison_scope"]["advertised_parity"], + ) + self.assertTrue(comparison["passed"]) + + def test_surface_parity_rejects_post_reveal_schema_drift(self) -> None: + classic = [ + {"name": "search_graph", "inputSchema": {"type": "object"}}, + ] + post = [ + { + "name": "search_graph", + "inputSchema": {"type": "object", "required": ["query"]}, + }, + ] + + comparison = BENCHMARK.compare_mcp_tool_surfaces( + classic, + post, + classic, + pre_dispatch={"search_graph": True}, + list_changed_observed=True, + ) + + self.assertFalse(comparison["post_reveal"]["classic_schema_parity"]) + self.assertEqual( + comparison["post_reveal"]["schema_mismatches"], ["search_graph"] + ) + self.assertFalse(comparison["passed"]) + + def test_surface_parity_rejects_post_reveal_protocol_contract_drift(self) -> None: + classic = [ + { + "name": "search_graph", + "description": "search", + "inputSchema": {"type": "object"}, + "outputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": True}, + } + ] + post = [ + { + **classic[0], + "annotations": {"readOnlyHint": False}, + } + ] + + comparison = BENCHMARK.compare_mcp_tool_surfaces( + classic, + post, + classic, + pre_dispatch={"search_graph": True}, + list_changed_observed=True, + ) + + self.assertFalse(comparison["post_reveal"]["classic_contract_parity"]) + self.assertEqual( + comparison["post_reveal"]["contract_mismatches"], ["search_graph"] + ) + self.assertFalse(comparison["passed"]) + + def test_explicit_config_override_takes_priority_over_profile(self) -> None: + overrides = BENCHMARK.resolve_config_overrides( + "minimal_indexing", ["rank_enabled=true", "auto_index_deps=true"] + ) + self.assertEqual(overrides["rank_enabled"], "true") + self.assertEqual(overrides["auto_index_deps"], "true") + self.assertEqual(overrides["semantic_edges_enabled"], "false") + + def test_benchmark_environment_retains_worker_measurement_log(self) -> None: + env = BENCHMARK.build_env(Path("/tmp/cbm-benchmark-cache")) + self.assertEqual(env["CBM_PROFILE"], "1") + self.assertEqual(env["CBM_AUTO_INDEX"], "false") + + def test_benchmark_environment_removes_inherited_product_configuration( + self, + ) -> None: + inherited = { + "CBM_TOOL_MODE": "classic", + "CBM_AUTO_INDEX_DEPS": "true", + "CBM_AUTO_DEP_LIMIT": "999", + "UNRELATED_BENCHMARK_ENV": "preserved", + } + with mock.patch.dict(os.environ, inherited, clear=False): + env = BENCHMARK.build_env(Path("/tmp/cbm-benchmark-isolated-cache")) + + self.assertNotIn("CBM_TOOL_MODE", env) + self.assertNotIn("CBM_AUTO_INDEX_DEPS", env) + self.assertNotIn("CBM_AUTO_DEP_LIMIT", env) + self.assertEqual(env["UNRELATED_BENCHMARK_ENV"], "preserved") + self.assertEqual(env["CBM_AUTO_INDEX"], "false") + self.assertEqual(env["CBM_CONTEXT_INJECTION"], "false") + self.assertEqual(env["CBM_PROFILE"], "1") + self.assertEqual( + BENCHMARK.benchmark_environment_policy(), + { + "inherited_product_environment": "remove_all_CBM_prefix_variables", + "harness_overrides": { + "CBM_AUTO_INDEX": "false", + "CBM_CONTEXT_INJECTION": "false", + "CBM_PROFILE": "1", + }, + "worker_selection": "candidate_default_with_CBM_WORKERS_unset", + "cache_scope": "isolated_per_benchmark_case", + }, + ) + + def test_explicit_product_environment_reaches_candidate_after_isolation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + env = BENCHMARK.build_env( + Path(tmpdir) / "candidate-cache", + {"CBM_WORKERS": "4", "CBM_MEM_BUDGET_MB": "512"}, + ) + + self.assertEqual(env["CBM_WORKERS"], "4") + self.assertEqual(env["CBM_MEM_BUDGET_MB"], "512") + self.assertEqual(env["CBM_AUTO_INDEX"], "false") + self.assertEqual(env["CBM_CONTEXT_INJECTION"], "false") + self.assertEqual(env["CBM_PROFILE"], "1") + self.assertEqual( + BENCHMARK.benchmark_environment_policy( + {"CBM_WORKERS": "4", "CBM_MEM_BUDGET_MB": "512"} + )["worker_selection"], + "explicit_CBM_WORKERS=4", + ) + + def test_product_environment_rejects_non_product_and_harness_owned_keys( + self, + ) -> None: + for item, expected in ( + ("PATH=/tmp/bin", "must start with CBM_"), + ("CBM_CACHE_DIR=/tmp/live", "owned by the benchmark harness"), + ("CBM_PROFILE=0", "owned by the benchmark harness"), + ): + with self.subTest(item=item), self.assertRaisesRegex(SystemExit, expected): + BENCHMARK.parse_product_environment([item]) + + def test_tool_result_separates_default_payload_quality_json_and_transport( + self, + ) -> None: + default_payload = b"total: 1\nresults[1]{name}:\n alpha\n" + result = BENCHMARK.build_tool_call_result( + {"name": "alpha", "items": [1, 2]}, + "", + 999, + 12.5, + False, + default_payload, + ) + canonical = b'{"items":[1,2],"name":"alpha"}' + self.assertEqual(result["transport_response_bytes"], 999) + self.assertEqual(result["response_bytes"], len(default_payload)) + self.assertEqual(result["quality_response_bytes"], len(canonical)) + self.assertEqual( + result["response_token_estimate"], + BENCHMARK.estimate_response_tokens(default_payload), + ) + self.assertEqual(result["token_estimator"], "utf8_bytes_div_4_ceil") + self.assertEqual(result["response_encoding"], "tool_default") + + def test_result_text_extractors_preserve_default_toon(self) -> None: + toon = "total: 1\nresults[1]{name}:\n alpha\n" + cli_stdout = '{"content":[{"type":"text","text":"total: 1\\nresults[1]{name}:\\n alpha\\n"}]}' + mcp_response = {"result": {"content": [{"type": "text", "text": toon}]}} + self.assertEqual(BENCHMARK.cli_result_text(cli_stdout), toon) + self.assertEqual(BENCHMARK.mcp_result_text(mcp_response), toon) + + def test_mcp_result_text_skips_prepended_update_notice(self) -> None: + payload = '{"status":"indexed"}' + response = { + "result": { + "content": [ + {"type": "text", "text": "Update available: dev -> v0.9.0"}, + {"type": "text", "text": payload}, + ] + } + } + + self.assertEqual(BENCHMARK.mcp_result_text(response), payload) + + def test_mcp_tool_call_measures_default_payload_and_uses_json_for_quality( + self, + ) -> None: + class FakeClient: + def __init__(self): + self.quality_calls = [] + + def call_tool_text(self, name, arguments): + self.default_call = (name, arguments) + return ( + "total: 1\nresults[1]{name}:\n alpha\n", + "default log", + 321, + 7.25, + ) + + def call_tool(self, name, arguments): + self.quality_calls.append((name, arguments)) + elapsed = (2.5, 0.5, 0.75)[len(self.quality_calls) - 1] + return {"results": [{"name": "alpha"}]}, "quality log", 654, elapsed + + client = FakeClient() + result = BENCHMARK.run_mcp_tool_call( + client, "search_graph", {"name_pattern": "alpha"}, False + ) + + self.assertEqual( + client.default_call, ("search_graph", {"name_pattern": "alpha"}) + ) + self.assertEqual( + client.quality_calls, + [ + ("search_graph", {"name_pattern": "alpha", "format": "json"}), + ("search_graph", {"name_pattern": "alpha", "format": "json"}), + ("search_graph", {"name_pattern": "alpha", "format": "json"}), + ], + ) + self.assertEqual(result["elapsed_ms"], 7.25) + self.assertEqual(result["quality_probe_elapsed_ms"], 2.5) + self.assertEqual(result["repeated_json_trials_ms"], [2.5, 0.5, 0.75]) + self.assertEqual( + result["repeated_json_latency_ms"], + {"count": 3, "min": 0.5, "median": 0.75, "max": 2.5}, + ) + self.assertTrue(result["repeated_json_payloads_byte_equal"]) + self.assertEqual(len(result["repeated_json_response_sha256"]), 3) + self.assertEqual(result["transport_response_bytes"], 321) + self.assertEqual(result["response_encoding"], "tool_default") + self.assertEqual(result["response"]["results"][0]["name"], "alpha") + + def test_quality_summary_requires_every_applicable_oracle(self) -> None: + oracles = { + "marker_search_graph": { + "response": {"results": [{"name": "wanted_marker"}]} + }, + "changed_file_query_graph": { + "response": {"results": [{"file_path": "wrong.c"}]} + }, + "route_freshness_probe": {"response": {"routes": []}}, + } + expectations = { + "marker_search_graph": ("wanted_marker", "marker returned"), + "changed_file_query_graph": ("src/wanted.c", "changed path returned"), + "route_freshness_probe": (None, "route check not applicable"), + } + summary = BENCHMARK.score_quality_oracles(oracles, expectations) + self.assertFalse(summary["passed"]) + self.assertEqual(summary["passed_count"], 1) + self.assertEqual(summary["applicable_count"], 2) + self.assertEqual(summary["score"], 0.5) + self.assertFalse(oracles["changed_file_query_graph"]["quality"]["passed"]) + self.assertFalse(oracles["route_freshness_probe"]["quality"]["applicable"]) + + def test_quality_summary_records_rank_and_hit_rates(self) -> None: + oracles = { + "ranked": { + "response": { + "results": [ + {"name": "unrelated"}, + {"name": "wanted_marker"}, + ] + } + } + } + summary = BENCHMARK.score_quality_oracles( + oracles, {"ranked": ("wanted_marker", "marker is ranked")} + ) + quality = oracles["ranked"]["quality"] + self.assertEqual(quality["rank"], 2) + self.assertFalse(quality["hit_at_1"]) + self.assertTrue(quality["hit_at_5"]) + self.assertEqual(quality["reciprocal_rank"], 0.5) + self.assertEqual(quality["returned_count"], 2) + self.assertEqual(summary["mean_reciprocal_rank"], 0.5) + self.assertEqual(summary["hit_at_1"], 0.0) + self.assertEqual(summary["hit_at_5"], 1.0) + self.assertEqual(summary["score"], 0.5) + + def test_binary_metadata_records_content_identity(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + binary = Path(tmpdir) / "cbm" + binary.write_bytes(b"auditable-binary") + metadata = BENCHMARK.binary_metadata(binary) + self.assertEqual(metadata["size_bytes"], 16) + self.assertEqual( + metadata["sha256"], + "5d984f78de8a55923b5ab343710b12830af15f8415f350135e5346fc7753b4d5", + ) + self.assertTrue(metadata["path"].endswith("/cbm")) + + def test_normalize_benchmark_report_records_experiment_identity_and_steps( + self, + ) -> None: + incremental = { + "elapsed_ms": 40, + "peak_rss_mb": 128, + "timing_components_ms": { + "main_index": 20, + "dependency_index": 10, + "rank_refresh": 5, + "worker_total": 35, + "cold_process_and_supervisor": 5, + }, + } + report = { + "generated_at_utc": "2026-07-22T08:00:00+00:00", + "binary_metadata": {"path": "/tmp/cbm", "sha256": "b" * 64}, + "parameters": { + "config_profile": "default", + "config_overrides": {"auto_index_deps": "true"}, + "index_mode": "full", + "rank_refresh": "eager", + "transport": "mcp", + }, + "measurements": { + "initial_fast_full": {"elapsed_ms": 100}, + "incremental_exact": incremental, + "incremental": incremental, + "fresh_fast_full_after_change": {"elapsed_ms": 90}, + }, + "derived": {"passed": True}, + } + context = { + "cell_identity": "cell-1", + "label": "candidate.default.mcp", + "revision": "a" * 40, + "repetition": 3, + "build": {"compiler": "clang", "cflags": "-O2"}, + "capabilities": {"rank_enabled": "true"}, + "source_git": {"head": "b" * 40, "branch": "fixture"}, + } + + facts = BENCHMARK.normalize_benchmark_report(report, context) + BENCHMARK.validate_benchmark_facts(facts) + + self.assertEqual( + facts["terminology_version"], BENCHMARK.BENCHMARK_TERMINOLOGY_VERSION + ) + self.assertEqual( + facts["terminology_sha256"], BENCHMARK.BENCHMARK_TERMINOLOGY_SHA256 + ) + self.assertRegex(facts["generator_revision"], r"^[0-9a-f]{64}$") + run = facts["runs"][0] + self.assertEqual(run["implementation"]["revision"], "a" * 40) + self.assertEqual(run["implementation"]["build"]["cflags"], "-O2") + self.assertEqual(run["repetition"], 3) + self.assertEqual(run["measurement_checkout"]["head"], "b" * 40) + self.assertEqual(run["capabilities"]["values"]["rank_enabled"], "true") + self.assertEqual(run["capabilities"]["values"]["index_mode"], "full") + self.assertEqual( + run["capabilities"]["requested_config_overrides"], + {"auto_index_deps": "true"}, + ) + self.assertEqual( + run["capabilities"]["effective_config_overrides"], + {"auto_index_deps": "true"}, + ) + self.assertEqual(run["capabilities"]["completeness"], "complete_declared_cell") + self.assertFalse(run["legacy_import"]) + parent_steps = [ + row for row in facts["steps"] if row["step_id"] == "incremental_index" + ] + self.assertEqual(len(parent_steps), 1) + component = next( + row for row in facts["steps"] if row["step_id"] == "dependency_index" + ) + self.assertEqual( + component["parent_occurrence_id"], parent_steps[0]["occurrence_id"] + ) + self.assertEqual(component["elapsed_ms"], 10.0) + + def test_normalize_self_dogfood_report_preserves_recorded_scope_and_cache( + self, + ) -> None: + scope = { + "workload": "self_dogfood", + "input_tree": { + "file_count": 321, + "source": "git_ls_tree_at_declared_revision", + }, + "mutation_policy": { + "kind": "deterministic_named_mutations", + "scenarios": [ + { + "name": "c_new_leaf", + "changed_paths": ["src/cbm_benchmark_leaf.c"], + } + ], + }, + } + cache = { + "process": { + "state": "persistent_within_lifecycle", + "source": "transport_contract", + }, + "repository_graph": { + "initial_state": "empty_harness_owned_cache", + "reset_procedure": "remove_project_dbs_before_clean_rebuild", + }, + "os_page_cache": { + "state": "uncontrolled", + "scheduling": "paired_interleaved", + }, + } + report = { + "mode": "self_dogfood", + "parameters": {"transport": "mcp"}, + "scope": scope, + "cache": cache, + "derived": {"passed": True}, + } + + run = BENCHMARK.normalize_benchmark_report( + report, + { + "cell_identity": "recorded-manifest", + "label": "latest.full.mcp.c_new_leaf", + "revision": "a" * 40, + "repetition": 1, + "build": {"compiler": "clang", "cflags": "-O2"}, + "capabilities": {"rank_enabled": "true"}, + }, + )["runs"][0] + + self.assertEqual(run["scope"], scope) + self.assertEqual(run["cache"], cache) + + def test_self_dogfood_manifests_record_harness_known_state_without_unknowns( + self, + ) -> None: + scope = BENCHMARK.self_dogfood_scope_manifest( + "a" * 40, + "b" * 40, + ["c_new_leaf"], + ) + cache = BENCHMARK.self_dogfood_cache_manifest( + mock.Mock( + transport="mcp", + config_overrides={"auto_index_deps": "false"}, + ) + ) + + self.assertEqual(scope["input_tree"]["revision"], "a" * 40) + self.assertEqual(scope["input_tree"]["tree"], "b" * 40) + self.assertEqual( + scope["mutation_policy"]["scenarios"], + [ + { + "name": "c_new_leaf", + "changed_paths": ["src/cbm_benchmark_leaf.c"], + } + ], + ) + self.assertEqual( + cache["dependency_artifacts"]["state"], + "disabled_by_explicit_config", + ) + self.assertEqual(cache["os_page_cache"]["state"], "uncontrolled_by_harness") + self.assertNotIn( + '"status": "unknown"', json.dumps({"scope": scope, "cache": cache}) + ) + + def test_normalize_legacy_report_marks_unavailable_metadata_unknown(self) -> None: + report = { + "generated_at_utc": "2026-07-20T00:00:00+00:00", + "binary_metadata": {"path": "/old/cbm", "sha256": "c" * 64}, + "parameters": {"transport": "cli"}, + "measurements": {"incremental": {"elapsed_ms": 25}}, + "derived": {"passed": False}, + } + + facts = BENCHMARK.normalize_benchmark_report(report) + + run = facts["runs"][0] + self.assertTrue(run["legacy_import"]) + self.assertEqual(run["implementation"]["revision"]["status"], "unknown") + self.assertEqual(run["implementation"]["build"]["status"], "unknown") + self.assertEqual(run["repetition"]["status"], "unknown") + self.assertEqual(run["capabilities"]["completeness"], "partial") + self.assertEqual(run["cache"]["process"]["status"], "unknown") + self.assertEqual(run["cache"]["repository_graph"]["status"], "unknown") + self.assertEqual(facts["steps"][0]["cpu_ms"]["status"], "unknown") + self.assertEqual(facts["results"][0]["status"], "failed") + + def test_imported_report_with_embedded_context_remains_an_import(self) -> None: + facts = BENCHMARK.normalize_benchmark_report( + { + "parameters": {"transport": "mcp"}, + "measurements": {"incremental": {"elapsed_ms": 25}}, + "derived": {"passed": True}, + }, + {"cell_identity": "retained-cell", "label": "retained"}, + imported_report=True, + ) + + run = facts["runs"][0] + self.assertTrue(run["legacy_import"]) + self.assertEqual(run["cell_identity"], "retained-cell") + self.assertEqual(run["cache"]["process"]["status"], "unknown") + + def test_candidate_native_fact_manifest_does_not_invent_effective_defaults( + self, + ) -> None: + report = { + "parameters": { + "config_profile": "candidate_native_configuration", + "config_overrides": {}, + "transport": "mcp", + }, + "measurements": {"incremental": {"elapsed_ms": 25}}, + "derived": {"passed": True}, + } + context = {"capabilities": {}, "label": "upstream-main"} + + run = BENCHMARK.normalize_benchmark_report(report, context)["runs"][0] + + self.assertEqual(run["capabilities"]["completeness"], "partial") + self.assertEqual( + run["capabilities"]["effective_config_overrides"]["status"], "unknown" + ) + self.assertEqual( + run["capabilities"]["provenance"], "candidate_native_configuration" + ) + + def test_standalone_context_does_not_attribute_checkout_head_to_binary( + self, + ) -> None: + args = mock.Mock( + binary=str(SCRIPT), + repo_root=str(SCRIPT.parents[1]), + timeout=30, + candidate_revision="", + build_metadata={}, + ) + + context = BENCHMARK.standalone_run_context(args) + + self.assertNotIn("revision", context) + self.assertRegex(context["checkout_revision"], r"^[0-9a-f]{40}$") + self.assertEqual(context["source_git"]["head"], context["checkout_revision"]) + + facts = BENCHMARK.normalize_benchmark_report( + {"source_git": {"head": context["checkout_revision"]}}, context + ) + self.assertEqual( + facts["runs"][0]["implementation"]["revision"]["status"], "unknown" + ) + + def test_write_benchmark_fact_tables_writes_hashed_manifest(self) -> None: + facts = BENCHMARK.normalize_benchmark_report( + { + "generated_at_utc": "2026-07-22T08:00:00+00:00", + "binary_metadata": {"path": "/tmp/cbm", "sha256": "d" * 64}, + "parameters": {"transport": "cli"}, + "measurements": {"incremental": {"elapsed_ms": 5}}, + "derived": {"passed": True}, + } + ) + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "facts" + manifest = BENCHMARK.write_benchmark_fact_tables(facts, root) + manifest_document = json.loads((root / "manifest.json").read_text()) + bundle = json.loads((root / "facts.json").read_text()) + step_rows = [ + json.loads(line) + for line in (root / "steps.jsonl").read_text().splitlines() + ] + + self.assertEqual(manifest_document["run_id"], facts["runs"][0]["run_id"]) + self.assertEqual( + manifest_document["terminology_sha256"], + BENCHMARK.BENCHMARK_TERMINOLOGY_SHA256, + ) + self.assertEqual(bundle["$schema"], BENCHMARK.BENCHMARK_FACT_SCHEMA) + self.assertNotIn("$schema", manifest_document) + self.assertEqual(manifest["files"]["bundle"]["rows"], 3) + self.assertEqual(manifest["files"]["steps"]["rows"], 1) + self.assertEqual(step_rows[0]["step_id"], "incremental_index") + self.assertRegex(manifest["manifest_sha256"], r"^[0-9a-f]{64}$") + + def test_benchmark_terminology_registry_is_unique_complete_and_generated( + self, + ) -> None: + entries = BENCHMARK.BENCHMARK_TERMINOLOGY["entries"] + term_ids = [entry["term_id"] for entry in entries] + self.assertEqual(len(term_ids), len(set(term_ids))) + self.assertIn("lifecycle_wall_time", term_ids) + self.assertIn("capability_delta_comparison", term_ids) + self.assertIn("dependency_package_index", term_ids) + required = { + "term_id", + "display_name", + "definition", + "status", + "kind", + "data_type", + "allowed_values_or_range", + "unit", + "clock_or_cpu_scope", + "boundary_semantics", + "aggregation_rule", + "concurrency_rule", + "missing_or_unsupported_behavior", + "configuration_precedence", + "capability_or_freshness_implications", + "source_anchors", + "introduced_version", + "deprecated_replacement", + "examples", + } + for entry in entries: + self.assertEqual(set(entry), required, entry["term_id"]) + generator = SCRIPT.with_name("generate_terminology.py") + process = subprocess.run( + [sys.executable, str(generator), "--check"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(process.returncode, 0, process.stderr) + + def test_describe_terms_does_not_require_benchmark_binary(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + missing_binary = Path(tmpdir) / "missing" + process = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--describe-terms", + "json", + "--binary", + str(missing_binary), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(process.returncode, 0, process.stderr) + registry = json.loads(process.stdout) + self.assertEqual( + registry["terminology_version"], + BENCHMARK.BENCHMARK_TERMINOLOGY_VERSION, + ) + + def test_load_retained_v1_fact_bundle_preserves_unknown_terminology(self) -> None: + facts = BENCHMARK.normalize_benchmark_report( + { + "measurements": {"incremental": {"elapsed_ms": 5}}, + "derived": {"passed": True}, + } + ) + facts["$schema"] = BENCHMARK.BENCHMARK_FACT_LEGACY_SCHEMAS[1] + facts["schema_version"] = 1 + del facts["terminology_version"] + del facts["terminology_sha256"] + del facts["generator_revision"] + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "facts-v1.json" + path.write_text(json.dumps(facts), encoding="utf-8") + loaded = BENCHMARK.load_benchmark_fact_bundle(path) + self.assertEqual(loaded["schema_version"], 1) + self.assertNotIn("terminology_version", loaded) + self.assertEqual(loaded["steps"][0]["elapsed_ms"], 5.0) + + def test_load_retained_v2_fact_bundle_accepts_old_uri_and_contract_hash( + self, + ) -> None: + facts = BENCHMARK.normalize_benchmark_report( + { + "measurements": {"incremental": {"elapsed_ms": 7}}, + "derived": {"passed": True}, + } + ) + facts["$schema"] = "docs/schema/benchmark-facts-v2.schema.json" + facts["terminology_version"] = "1.0.0" + facts["terminology_sha256"] = "a" * 64 + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "facts-v2-old-uri.json" + path.write_text(json.dumps(facts), encoding="utf-8") + loaded = BENCHMARK.load_benchmark_fact_bundle(path) + + self.assertEqual(loaded["schema_version"], 2) + self.assertEqual(loaded["terminology_version"], "1.0.0") + self.assertEqual(loaded["terminology_sha256"], "a" * 64) + + def test_validate_benchmark_facts_rejects_schema_required_run_field_gap( + self, + ) -> None: + facts = BENCHMARK.normalize_benchmark_report( + {"derived": {"passed": True}}, {"label": "fixture"} + ) + del facts["runs"][0]["measurement_checkout"] + + with self.assertRaisesRegex(ValueError, "measurement_checkout"): + BENCHMARK.validate_benchmark_facts(facts) + + def test_import_report_cli_does_not_require_benchmark_binary(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + source = root / "legacy.json" + facts_dir = root / "facts" + source.write_text( + json.dumps( + { + "generated_at_utc": "2026-07-20T00:00:00+00:00", + "binary_metadata": {"path": "/gone/cbm", "sha256": "e" * 64}, + "parameters": {"transport": "cli"}, + "measurements": {"incremental": {"elapsed_ms": 7}}, + "derived": {"passed": True}, + } + ), + encoding="utf-8", + ) + source_sha256 = BENCHMARK.file_sha256(source) + process = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--import-report", + str(source), + "--facts-dir", + str(facts_dir), + "--binary", + str(root / "missing-binary"), + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(process.returncode, 0, process.stderr) + artifacts = json.loads((facts_dir / "artifacts.json").read_text()) + self.assertTrue((facts_dir / "runs.json").is_file()) + self.assertEqual(artifacts[-1]["artifact_type"], "legacy_source_report") + self.assertEqual(artifacts[-1]["sha256"], source_sha256) + + def test_build_index_result_reports_maximum_logged_peak_rss(self) -> None: + stderr = "\n".join( + ( + "level=info msg=mem.phase phase=registry_build rss_mb=120 peak_mb=144", + "level=info msg=mem.phase phase=parallel_resolve rss_mb=192 peak_mb=256", + "level=info msg=pipeline.done elapsed_ms=80", + ) + ) + result = BENCHMARK.build_index_result( + {"publish_kind": "full"}, + stderr, + stdout_bytes=10, + elapsed_ms=100.0, + include_logs=False, + ) + self.assertEqual(result["peak_rss_mb"], 256) + + def test_build_index_result_reads_final_peak_for_sequential_and_incremental_runs( + self, + ) -> None: + for marker in ("pipeline.done", "incremental.done"): + with self.subTest(marker=marker): + result = BENCHMARK.build_index_result( + {"publish_kind": "incremental_exact"}, + f"level=info msg={marker} elapsed_ms=18 rss_mb=42 peak_mb=64", + stdout_bytes=10, + elapsed_ms=20.0, + include_logs=False, + ) + self.assertEqual(result["peak_rss_mb"], 64) + + def test_build_index_result_reads_bounded_worker_log_markers(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + logfile = Path(tmpdir) / "index.log" + logfile.write_text( + "ignored detail\n" + "level=info msg=mem.phase phase=parallel_resolve rss_mb=192 peak_mb=320\n" + "level=info msg=pipeline.done elapsed_ms=81\n", + encoding="utf-8", + ) + result = BENCHMARK.build_index_result( + {"publish_kind": "full", "logfile": "/missing/response.log"}, + ( + "level=info msg=index.supervisor.reap outcome=clean\n" + f"level=info msg=index.supervisor.profile_log log={logfile}" + ), + stdout_bytes=10, + elapsed_ms=100.0, + include_logs=False, + ) + + self.assertEqual(result["peak_rss_mb"], 320) + self.assertEqual(result["logged_elapsed_ms"]["pipeline_done"], 81) + self.assertEqual(len(result["measurement_log_markers"]), 2) + self.assertNotIn("ignored detail", "\n".join(result["measurement_log_markers"])) + + def test_run_index_mcp_reads_only_current_daemon_worker_log(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + cache_dir = root / "cache" + daemon_log = cache_dir / BENCHMARK.DAEMON_LOG_RELATIVE_PATH + daemon_log.parent.mkdir(parents=True) + old_worker_log = root / "old-worker.log" + current_worker_log = root / "current-worker.log" + old_worker_log.write_text( + "level=info msg=incremental.done elapsed_ms=999 " + "rss_mb=999 peak_mb=999\n", + encoding="utf-8", + ) + current_worker_log.write_text( + "level=info msg=incremental.done elapsed_ms=18 " + "rss_mb=42 peak_mb=64\n" + "level=info msg=prof phase=index_repository " + "sub=TOTAL ms=20 us=20000\n", + encoding="utf-8", + ) + daemon_log.write_text( + f"level=info msg=index.supervisor.profile_log log={old_worker_log}\n", + encoding="utf-8", + ) + + class FakeClient: + def __init__(self) -> None: + self.env = {"CBM_CACHE_DIR": str(cache_dir)} + + def call_tool_text( + self, name: str, arguments: dict[str, object] + ) -> tuple[str, str, int, float]: + self.name = name + self.arguments = arguments + with daemon_log.open("a", encoding="utf-8") as stream: + stream.write( + "level=info msg=index.supervisor.profile_log " + f"log={current_worker_log}\n" + ) + return ( + '{"publish_kind":"incremental_exact"}', + "", + 10, + 25.0, + ) + + client = FakeClient() + result = BENCHMARK.run_index_mcp(client, root / "repo", include_logs=False) + + self.assertEqual(client.name, "index_repository") + self.assertEqual(result["peak_rss_mb"], 64) + self.assertEqual(result["indexed_work_elapsed_ms"], 18) + self.assertEqual(result["worker_elapsed_ms"], 20) + self.assertEqual(result["process_overhead_ms"], 5) + + def test_build_index_result_archives_worker_log_before_worktree_cleanup( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + logfile = root / "index.log" + artifact_dir = root / "durable-artifacts" + logfile.write_text( + "level=info msg=mem.phase phase=parallel_resolve rss_mb=192 peak_mb=320\n", + encoding="utf-8", + ) + with mock.patch.dict( + os.environ, {BENCHMARK.BENCHMARK_ARTIFACT_DIR_ENV: str(artifact_dir)} + ): + result = BENCHMARK.build_index_result( + {"publish_kind": "full"}, + f"level=info msg=index.supervisor.profile_log log={logfile}", + stdout_bytes=10, + elapsed_ms=100.0, + include_logs=False, + ) + + artifact = result["measurement_log_artifacts"][0] + archived_path = artifact_dir / artifact["artifact_name"] + logfile.unlink() + + self.assertTrue(archived_path.is_file()) + self.assertIn( + "msg=mem.phase", gzip.decompress(archived_path.read_bytes()).decode() + ) + self.assertEqual(artifact["source_name"], "index.log") + + def test_build_index_result_records_dependency_phase_and_package_count( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + logfile = Path(tmpdir) / "index.log" + logfile.write_text( + "level=info msg=prof phase=index_repository " + "sub=dep_auto_index ms=52347 us=52347915\n", + encoding="utf-8", + ) + result = BENCHMARK.build_index_result( + {"publish_kind": "full", "dependencies_indexed": 6}, + f"level=info msg=index.supervisor.profile_log log={logfile}", + stdout_bytes=10, + elapsed_ms=60000.0, + include_logs=False, + ) + + self.assertEqual( + result["dependency_indexing"], + { + "measurement_status": "measured", + "phase_elapsed_ms": 52347, + "packages_indexed": 6, + }, + ) + self.assertIn( + "sub=dep_auto_index", "\n".join(result["measurement_log_markers"]) + ) + + def test_build_index_result_attributes_cold_process_overhead_after_worker_total( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + logfile = Path(tmpdir) / "index.log" + logfile.write_text( + "level=info msg=pipeline.done elapsed_ms=100\n" + "level=info msg=prof phase=index_repository sub=dep_auto_index ms=500 us=500000\n" + "level=info msg=prof phase=index_repository sub=rank_refresh ms=20 us=20000\n" + "level=info msg=prof phase=index_repository sub=TOTAL ms=650 us=650000\n", + encoding="utf-8", + ) + result = BENCHMARK.build_index_result( + {"publish_kind": "full", "dependencies_indexed": 1}, + f"level=info msg=index.supervisor.profile_log log={logfile}", + stdout_bytes=10, + elapsed_ms=2650.0, + include_logs=False, + ) + + self.assertEqual(result["worker_elapsed_ms"], 650) + self.assertEqual(result["process_overhead_ms"], 2000) + self.assertEqual(result["unlogged_overhead_ms"], 2000) + self.assertEqual( + result["timing_components_ms"], + { + "main_index": 100, + "dependency_index": 500, + "rank_refresh": 20, + "worker_total": 650, + "cold_process_and_supervisor": 2000, + }, + ) + + def test_build_index_result_marks_uninstrumented_dependency_phase_unknown( + self, + ) -> None: + result = BENCHMARK.build_index_result( + {"publish_kind": "full"}, + "", + stdout_bytes=10, + elapsed_ms=20.0, + include_logs=False, + ) + + self.assertEqual( + result["dependency_indexing"], + { + "measurement_status": "unknown", + "phase_elapsed_ms": None, + "packages_indexed": None, + }, + ) + + def test_route_handler_mutation_adds_executable_route_registration(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo = Path(tmpdir) + source = repo / "src" / "ui" / "http_server.c" + source.parent.mkdir(parents=True) + source.write_text("/* fixture */\n", encoding="utf-8") + + mutation = BENCHMARK.mutate_self_dogfood_scenario("route_handler", repo) + mutated = source.read_text(encoding="utf-8") + + self.assertEqual(mutation["changed_paths"], ["src/ui/http_server.c"]) + self.assertIn("cbm_pan4_oracle_route_handler", mutated) + self.assertIn('cbm_http_path_match(path, "/api/pan4-oracle")', mutated) + self.assertNotIn("route oracle literal", mutated) + + def test_c_new_leaf_mutation_adds_hashed_indexed_source_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo = Path(tmpdir) + source = repo / "src" / "cbm_benchmark_leaf.c" + mutation = BENCHMARK.mutate_self_dogfood_scenario("c_new_leaf", repo) + mutated = source.read_text(encoding="utf-8") + source_sha256 = BENCHMARK.file_sha256(source) + + self.assertEqual(mutation["changed_paths"], ["src/cbm_benchmark_leaf.c"]) + self.assertEqual(mutation["description"], "new isolated C source file") + self.assertIn("static int cbm_pan4_oracle_c_new_leaf(void)", mutated) + self.assertEqual( + mutation["source_hashes"], + [ + { + "path": "src/cbm_benchmark_leaf.c", + "before_sha256": None, + "after_sha256": source_sha256, + } + ], + ) + + def test_self_dogfood_worktree_uses_the_declared_revision(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + source_repo = Path(tmpdir) / "source" / "repo" + case_root = Path(tmpdir) / "experiment" / "cell" + completed = subprocess.CompletedProcess([], 0, "", "") + + with mock.patch.object( + BENCHMARK, "command_result", return_value=(completed, 1) + ) as run: + repo_dir = BENCHMARK.create_self_dogfood_worktree( + source_repo, + case_root, + 30, + "a" * 40, + ) + + self.assertEqual(repo_dir, case_root / BENCHMARK.SELF_DOGFOOD_REPO_SUBDIR) + self.assertEqual( + run.call_args.args[0], + [ + "git", + "worktree", + "add", + "--detach", + str(repo_dir), + "a" * 40, + ], + ) + + def test_build_index_result_uses_none_without_memory_markers(self) -> None: + result = BENCHMARK.build_index_result( + {"publish_kind": "full"}, + "level=info msg=pipeline.done elapsed_ms=80", + 10, + 100.0, + False, + ) + self.assertIsNone(result["peak_rss_mb"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_schema_declared_property_keys.c b/tests/test_schema_declared_property_keys.c new file mode 100644 index 000000000..38faa54a8 --- /dev/null +++ b/tests/test_schema_declared_property_keys.c @@ -0,0 +1,253 @@ +/* + * test_schema_declared_property_keys.c — Contract test for the declared + * node/edge property-key registry: the tables in src/store/store.c + * (schema_declared_node_property_keys / schema_declared_edge_property_keys) + * exposed via the accessors declared in src/store/store.h + * (cbm_store_schema_declared_node_property_keys / + * cbm_store_schema_declared_edge_property_keys). See the maintenance + * contract on those definitions for the full list of places that must stay + * in sync when a pipeline pass or git-context writer emits a new key. + * + * Indexes committed mixed-language fixtures, reads the discovered schema + * (base columns + JSON property keys) via cbm_store_get_schema, and asserts: + * 1. Every discovered non-base key is present in the declared registry + * for its entity kind (forward direction — the registry is complete). + * 2. The declared tables are sorted and duplicate-free (their sizeof-based + * accessors give no other way to detect drift). + * 3. A representative sample of declared keys is actually observed in the + * fixtures (reverse pin — catches dead/rotted registry rows). + */ +#include "test_framework.h" +#include "test_helpers.h" +#include "foundation/constants.h" +#include "foundation/platform.h" +#include "git/git_command.h" +#include "pipeline/pipeline.h" +#include "store/store.h" +#include +#include + +static char g_dpk_tmpdir[CBM_SZ_256]; + +static int dpk_setup_repo(const char **filenames, const char **contents, int count) { + const char *cache = cbm_resolve_cache_dir(); + int n = snprintf(g_dpk_tmpdir, sizeof(g_dpk_tmpdir), "%s/cbm-declared-keys-XXXXXX", cache); + if (n < 0 || (size_t)n >= sizeof(g_dpk_tmpdir) || !cbm_mkdtemp(g_dpk_tmpdir)) { + g_dpk_tmpdir[0] = '\0'; + return -1; + } + for (int i = 0; i < count; i++) { + if (th_write_file(TH_PATH(g_dpk_tmpdir, filenames[i]), contents[i]) != 0) { + th_rmtree(g_dpk_tmpdir); + g_dpk_tmpdir[0] = '\0'; + return -1; + } + } + return 0; +} + +/* Best-effort: turn the fixture dir into a real git repo so pass_structure + * (pipeline.c) creates a Branch node / HAS_BRANCH edge carrying every + * cbm_git_context_props_json key (is_git, is_worktree, is_detached, + * root_exists, canonical_root, worktree_root, git_common_dir, branch, + * head_sha, base_sha) — otherwise those ~10 registry rows are never + * forward-verified by this test. Non-fatal on failure (matches + * test_git_context.c's SKIP_PLATFORM tolerance for "git not available"); + * the 3-fixture-only forward check still runs either way. Returns true iff + * the repo was created, so callers can gate git-only reverse-pin keys. */ +static bool dpk_add_git_context(void) { + const char *const init_args[] = {"init", "-q", NULL}; + const char *const email_args[] = {"config", "user.email", "test@example.com", NULL}; + const char *const name_args[] = {"config", "user.name", "Test", NULL}; + const char *const add_args[] = {"add", ".", NULL}; + const char *const commit_args[] = {"commit", "-q", "-m", "init", NULL}; + if (cbm_git_drain_command(g_dpk_tmpdir, init_args) != 0 || + cbm_git_drain_command(g_dpk_tmpdir, email_args) != 0 || + cbm_git_drain_command(g_dpk_tmpdir, name_args) != 0 || + cbm_git_drain_command(g_dpk_tmpdir, add_args) != 0 || + cbm_git_drain_command(g_dpk_tmpdir, commit_args) != 0) { + return false; + } + return true; +} + +static void dpk_teardown_repo(void) { + if (g_dpk_tmpdir[0]) + th_rmtree(g_dpk_tmpdir); + g_dpk_tmpdir[0] = '\0'; +} + +static bool dpk_key_in(const char *key, const char *const *set, int count) { + for (int i = 0; i < count; i++) { + if (strcmp(key, set[i]) == 0) + return true; + } + return false; +} + +/* DPK_SAMPLE_MIN mirrors the plan's reverse-pin bar: enough hits to prove + * the sample set is actually observed, not a coincidence. The last two + * slots are git_context keys, only required when dpk_add_git_context() + * succeeded (see its use below). */ +enum { DPK_SAMPLE_MIN = 6, DPK_SAMPLE_MAX = 8 }; + +TEST(declared_node_property_keys_sorted_and_deduped) { + int count = 0; + const char *const *keys = cbm_store_schema_declared_node_property_keys(&count); + ASSERT_NOT_NULL(keys); + ASSERT_TRUE(count > 0); + for (int i = 1; i < count; i++) { + if (strcmp(keys[i - 1], keys[i]) >= 0) + printf("registry order violation: \"%s\" before \"%s\" (insert in ASCII order)\n", + keys[i - 1], keys[i]); + ASSERT_TRUE(strcmp(keys[i - 1], keys[i]) < 0); + } + PASS(); +} + +TEST(declared_edge_property_keys_sorted_and_deduped) { + int count = 0; + const char *const *keys = cbm_store_schema_declared_edge_property_keys(&count); + ASSERT_NOT_NULL(keys); + ASSERT_TRUE(count > 0); + for (int i = 1; i < count; i++) { + if (strcmp(keys[i - 1], keys[i]) >= 0) + printf("registry order violation: \"%s\" before \"%s\" (insert in ASCII order)\n", + keys[i - 1], keys[i]); + ASSERT_TRUE(strcmp(keys[i - 1], keys[i]) < 0); + } + PASS(); +} + +/* Index mixed-language fixtures (Python Flask route + class, TypeScript + * async route handler, Rust function) and assert every discovered non-base + * node/edge property key belongs to the declared registry for its kind. */ +TEST(declared_property_keys_cover_discovered_mixed_language_keys) { + const char *files[] = {"app.py", "handlers.ts", "lib.rs"}; + const char *contents[] = { + "from flask import Flask\n\napp = Flask(__name__)\n\n\n" + "class DataProcessor(BaseProcessor):\n" + " \"\"\"Transforms request payloads.\"\"\"\n\n" + " @staticmethod\n" + " def transform(data):\n" + " return data\n\n\n" + "@app.route(\"/items\")\n" + "def list_items():\n" + " \"\"\"Return all items.\"\"\"\n" + " items = []\n" + " for i in range(3):\n" + " items.append(i)\n" + " return {\"items\": items}\n", + "export async function handleRequest(req, res) {\n" + " const data = await fetch('/api/items');\n" + " return data;\n" + "}\n", + "pub fn add(a: i32, b: i32) -> i32 {\n" + " a + b\n" + "}\n"}; + + if (dpk_setup_repo(files, contents, 3) != 0) + FAIL("tmpdir"); + /* Best-effort: also forward-verify the ~10 git_context registry rows + * (see dpk_add_git_context). Never hard-fails the test — mirrors + * test_git_context.c's tolerance for "git not available". */ + bool have_git = dpk_add_git_context(); + + char db[CBM_SZ_512]; + snprintf(db, sizeof(db), "%s/test.db", g_dpk_tmpdir); + + cbm_pipeline_t *p = cbm_pipeline_new(g_dpk_tmpdir, db, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + const char *proj = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db); + ASSERT_NOT_NULL(s); + + cbm_schema_info_t schema = {0}; + ASSERT_EQ(cbm_store_get_schema(s, proj, &schema), CBM_STORE_OK); + + int base_node_count = 0; + const char *const *base_node_cols = cbm_store_schema_node_base_properties(&base_node_count); + int declared_node_count = 0; + const char *const *declared_node_keys = + cbm_store_schema_declared_node_property_keys(&declared_node_count); + + int base_edge_count = 0; + const char *const *base_edge_cols = cbm_store_schema_edge_base_properties(&base_edge_count); + int declared_edge_count = 0; + const char *const *declared_edge_keys = + cbm_store_schema_declared_edge_property_keys(&declared_edge_count); + + bool sample_seen[DPK_SAMPLE_MAX] = {0}; + const char *sample_keys[DPK_SAMPLE_MAX] = { + "complexity", "cognitive", "is_test", "is_exported", + "docstring", "base_classes", "branch", "is_git"}; + int sample_count = have_git ? DPK_SAMPLE_MAX : DPK_SAMPLE_MIN; + + bool undeclared_found = false; + for (int i = 0; i < schema.node_label_count; i++) { + const cbm_label_count_t *lc = &schema.node_labels[i]; + /* If discovery hit the cap, this test can no longer prove registry + * completeness for this label — fail loudly instead of passing blind. */ + if (lc->property_count >= CBM_STORE_SCHEMA_PROPERTY_KEY_LIMIT) { + printf("label %s hit the %d-key discovery cap; completeness unprovable\n", lc->label, + CBM_STORE_SCHEMA_PROPERTY_KEY_LIMIT); + undeclared_found = true; + } + for (int j = 0; j < lc->property_count; j++) { + const char *key = lc->properties[j]; + if (dpk_key_in(key, base_node_cols, base_node_count)) + continue; + if (!dpk_key_in(key, declared_node_keys, declared_node_count)) { + printf("undeclared node property key: %s (label %s)\n", key, lc->label); + undeclared_found = true; + } + for (int k = 0; k < sample_count; k++) { + if (!sample_seen[k] && strcmp(key, sample_keys[k]) == 0) + sample_seen[k] = true; + } + } + } + + for (int i = 0; i < schema.edge_type_count; i++) { + const cbm_type_count_t *tc = &schema.edge_types[i]; + if (tc->property_count >= CBM_STORE_SCHEMA_PROPERTY_KEY_LIMIT) { + printf("edge type %s hit the %d-key discovery cap; completeness unprovable\n", + tc->type, CBM_STORE_SCHEMA_PROPERTY_KEY_LIMIT); + undeclared_found = true; + } + for (int j = 0; j < tc->property_count; j++) { + const char *key = tc->properties[j]; + if (dpk_key_in(key, base_edge_cols, base_edge_count)) + continue; + if (!dpk_key_in(key, declared_edge_keys, declared_edge_count)) { + printf("undeclared edge property key: %s (type %s)\n", key, tc->type); + undeclared_found = true; + } + } + } + + bool all_samples_seen = true; + for (int k = 0; k < sample_count; k++) { + if (!sample_seen[k]) { + printf("reverse-pin sample key never observed: %s\n", sample_keys[k]); + all_samples_seen = false; + } + } + + cbm_store_schema_free(&schema); + cbm_store_close(s); + cbm_pipeline_free(p); + dpk_teardown_repo(); + + ASSERT_TRUE(!undeclared_found); + ASSERT_TRUE(all_samples_seen); + PASS(); +} + +SUITE(schema_declared_property_keys) { + RUN_TEST(declared_node_property_keys_sorted_and_deduped); + RUN_TEST(declared_edge_property_keys_sorted_and_deduped); + RUN_TEST(declared_property_keys_cover_discovered_mixed_language_keys); +} diff --git a/tests/test_security.c b/tests/test_security.c index 34676620e..c355c77a6 100644 --- a/tests/test_security.c +++ b/tests/test_security.c @@ -12,6 +12,7 @@ #include #include "../src/foundation/str_util.h" #include "../src/foundation/compat_fs.h" +#include "../src/foundation/compat_thread.h" #ifdef _WIN32 #include "../src/foundation/compat_fs_internal.h" #include "../src/foundation/win_utf8.h" @@ -763,6 +764,208 @@ TEST(popen_isolates_listening_socket) { #endif /* _WIN32 */ +TEST(pclose_exit_code_normalizes_platform_status) { + FILE *fp = cbm_popen("exit 7", "r"); + ASSERT_NOT_NULL(fp); + ASSERT_EQ(cbm_pclose_exit_code(fp), 7); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * PORTABLE FILE REPLACEMENT + * ══════════════════════════════════════════════════════════════════ */ + +TEST(compat_replace_file_replaces_destination) { + char *dir = th_mktempdir("cbm_replace_file"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "target.txt"); + const char *tmp = TH_PATH(root, "target.txt.tmp"); + ASSERT_EQ(th_write_file(dest, "old"), 0); + ASSERT_EQ(th_write_file(tmp, "new"), 0); + + ASSERT_EQ(cbm_replace_file(tmp, dest), 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[8] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_EQ((int)n, 3); + ASSERT_STR_EQ(buf, "new"); + + struct stat st; + ASSERT_NEQ(stat(tmp, &st), 0); + th_cleanup(root); + PASS(); +} + +TEST(compat_move_file_no_replace_preserves_existing_destination) { + char *dir = th_mktempdir("cbm_move_file_no_replace"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "target.txt"); + const char *src = TH_PATH(root, "source.txt"); + ASSERT_EQ(th_write_file(dest, "old"), 0); + ASSERT_EQ(th_write_file(src, "new"), 0); + + ASSERT_NEQ(cbm_move_file_no_replace(src, dest), 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[8] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_EQ((int)n, 3); + ASSERT_STR_EQ(buf, "old"); + + struct stat st; + ASSERT_EQ(stat(src, &st), 0); + th_cleanup(root); + PASS(); +} + +TEST(compat_move_file_no_replace_moves_when_destination_missing) { + char *dir = th_mktempdir("cbm_move_file_no_replace_ok"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "target.txt"); + const char *src = TH_PATH(root, "source.txt"); + ASSERT_EQ(th_write_file(src, "new"), 0); + + ASSERT_EQ(cbm_move_file_no_replace(src, dest), 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[8] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_EQ((int)n, 3); + ASSERT_STR_EQ(buf, "new"); + + struct stat st; + ASSERT_NEQ(stat(src, &st), 0); + th_cleanup(root); + PASS(); +} + +TEST(compat_write_file_atomic_replaces_destination) { + char *dir = th_mktempdir("cbm_write_file_atomic"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "payload.bin"); + ASSERT_EQ(th_write_file(dest, "old"), 0); + + cbm_atomic_file_error_t err = {0}; + ASSERT_EQ(cbm_write_file_atomic(dest, "new", 3, &err), 0); + ASSERT_NULL(err.stage); + ASSERT_EQ(err.code, 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[8] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_EQ((int)n, 3); + ASSERT_STR_EQ(buf, "new"); + + struct stat st; + ASSERT_NEQ(stat(TH_PATH(root, "payload.bin.tmp"), &st), 0); + th_cleanup(root); + PASS(); +} + +TEST(compat_write_file_atomic_reports_replace_failure) { + char *dir = th_mktempdir("cbm_write_file_atomic_fail"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + const char *dest = TH_PATH(root, "target.txt"); + ASSERT_TRUE(cbm_mkdir_p(dest, 0755)); + + cbm_atomic_file_error_t err = {0}; + ASSERT_NEQ(cbm_write_file_atomic(dest, "new", 3, &err), 0); + ASSERT_NOT_NULL(err.stage); + ASSERT_STR_EQ(err.stage, "rename_temp"); + ASSERT_NEQ(err.code, 0); + + th_cleanup(root); + PASS(); +} + +enum { ATOMIC_CONCURRENT_WRITES = 64 }; + +typedef struct { + const char *dest; + const char *payload; + int failures; + const char *first_failure_stage; + int first_failure_code; +} atomic_writer_arg_t; + +static void *atomic_writer_thread(void *arg) { + atomic_writer_arg_t *wa = (atomic_writer_arg_t *)arg; + size_t len = strlen(wa->payload); + for (int i = 0; i < ATOMIC_CONCURRENT_WRITES; i++) { + cbm_atomic_file_error_t err = {0}; + if (cbm_write_file_atomic(wa->dest, wa->payload, len, &err) != 0) { + if (wa->failures == 0) { + wa->first_failure_stage = err.stage; + wa->first_failure_code = err.code; + } + wa->failures++; + } + } + return NULL; +} + +TEST(compat_write_file_atomic_concurrent_same_destination) { + char *dir = th_mktempdir("cbm_write_file_atomic_concurrent"); + ASSERT_NOT_NULL(dir); + char root[256]; + snprintf(root, sizeof(root), "%s", dir); + + char dest[512]; + snprintf(dest, sizeof(dest), "%s", TH_PATH(root, "payload.bin")); + ASSERT_EQ(th_write_file(dest, "initial"), 0); + + atomic_writer_arg_t a = {.dest = dest, .payload = "alpha", .failures = 0}; + atomic_writer_arg_t b = {.dest = dest, .payload = "bravo", .failures = 0}; + cbm_thread_t ta, tb; + ASSERT_EQ(cbm_thread_create(&ta, 0, atomic_writer_thread, &a), 0); + ASSERT_EQ(cbm_thread_create(&tb, 0, atomic_writer_thread, &b), 0); + ASSERT_EQ(cbm_thread_join(&ta), 0); + ASSERT_EQ(cbm_thread_join(&tb), 0); + if (a.failures != 0 || b.failures != 0) { + printf(" atomic writer failures: a=%d stage=%s code=%d; b=%d stage=%s code=%d\n", + a.failures, a.first_failure_stage ? a.first_failure_stage : "none", + a.first_failure_code, b.failures, + b.first_failure_stage ? b.first_failure_stage : "none", b.first_failure_code); + } + ASSERT_EQ(a.failures, 0); + ASSERT_EQ(b.failures, 0); + + FILE *fp = fopen(dest, "rb"); + ASSERT_NOT_NULL(fp); + char buf[16] = {0}; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + ASSERT_TRUE((n == strlen(a.payload) && strcmp(buf, a.payload) == 0) || + (n == strlen(b.payload) && strcmp(buf, b.payload) == 0)); + + th_cleanup(root); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -839,4 +1042,12 @@ SUITE(security) { RUN_TEST(popen_isolated_propagates_exit_code); RUN_TEST(popen_isolates_listening_socket); #endif + + RUN_TEST(pclose_exit_code_normalizes_platform_status); + RUN_TEST(compat_replace_file_replaces_destination); + RUN_TEST(compat_move_file_no_replace_preserves_existing_destination); + RUN_TEST(compat_move_file_no_replace_moves_when_destination_missing); + RUN_TEST(compat_write_file_atomic_replaces_destination); + RUN_TEST(compat_write_file_atomic_reports_replace_failure); + RUN_TEST(compat_write_file_atomic_concurrent_same_destination); } diff --git a/tests/test_simhash.c b/tests/test_simhash.c index f22df753d..a48ca7365 100644 --- a/tests/test_simhash.c +++ b/tests/test_simhash.c @@ -13,6 +13,7 @@ #include "graph_buffer/graph_buffer.h" #include "pipeline/pipeline_internal.h" #include "pipeline/pipeline.h" +#include "cli/cli.h" #include "store/store.h" #include "foundation/compat.h" @@ -41,6 +42,63 @@ static const CBMDefinition *find_def(const CBMFileResult *r, const char *name) { return NULL; } +/* Build two functions with an identical, structurally meaningful prefix whose + * leaf-token count exceeds the former 4096-token MinHash prefix. The optional + * suffix then proves that structure after that boundary affects the result. */ +static char *build_long_minhash_source(const char *name, bool add_distinct_suffix) { + enum { MINHASH_LONG_PREFIX_STATEMENTS = 1800 }; + char *source = malloc(CBM_SZ_64K); + if (!source) { + return NULL; + } + int n = snprintf(source, CBM_SZ_64K, + "package main\n" + "func %s(x int) int {\n" + " if x > 0 { x-- } else { x++ }\n" + " for i := 0; i < 8; i++ { x += i }\n" + " switch x {\n" + " case 1: x *= 2\n" + " case 2: x /= 2\n" + " default: x %%= 3\n" + " }\n" + " values := []int{1, 2, 3}\n" + " for _, value := range values { x += value }\n" + " defer func() { x++ }()\n", + name); + if (n <= 0 || (size_t)n >= CBM_SZ_64K) { + free(source); + return NULL; + } + size_t used = (size_t)n; + for (int i = 0; i < MINHASH_LONG_PREFIX_STATEMENTS; i++) { + n = snprintf(source + used, CBM_SZ_64K - used, " x += 1\n"); + if (n <= 0 || (size_t)n >= CBM_SZ_64K - used) { + free(source); + return NULL; + } + used += (size_t)n; + } + const char *suffix = + add_distinct_suffix + ? " ch := make(chan int, 1)\n" + " select {\n" + " case ch <- x: x = <-ch\n" + " default: close(ch)\n" + " }\n" + " labels := map[string]int{\"value\": x}\n" + " for key, value := range labels {\n" + " if len(key) > 0 && value != 0 { x += value }\n" + " }\n" + " go func(value int) { _ = value }(x)\n" + : ""; + n = snprintf(source + used, CBM_SZ_64K - used, "%s return x\n}\n", suffix); + if (n <= 0 || (size_t)n >= CBM_SZ_64K - used) { + free(source); + return NULL; + } + return source; +} + /* Count SIMILAR_TO edges in graph buffer. */ static int count_similar_to_edges(const cbm_gbuf_t *gb) { int count = 0; @@ -395,6 +453,38 @@ TEST(minhash_type_annotation_normalized) { PASS(); } +TEST(minhash_reads_structure_after_former_token_prefix) { + char *src_without_suffix = build_long_minhash_source("LongPrefixOnly", false); + char *src_with_suffix = build_long_minhash_source("LongPrefixWithSuffix", true); + ASSERT_NOT_NULL(src_without_suffix); + ASSERT_NOT_NULL(src_with_suffix); + + CBMFileResult *without_result = + extract_one(src_without_suffix, CBM_LANG_GO, "test", "without_suffix.go"); + CBMFileResult *with_result = + extract_one(src_with_suffix, CBM_LANG_GO, "test", "with_suffix.go"); + ASSERT_NOT_NULL(without_result); + ASSERT_NOT_NULL(with_result); + + const CBMDefinition *without_def = find_def(without_result, "LongPrefixOnly"); + const CBMDefinition *with_def = find_def(with_result, "LongPrefixWithSuffix"); + ASSERT_NOT_NULL(without_def); + ASSERT_NOT_NULL(with_def); + ASSERT_NOT_NULL(without_def->fingerprint); + ASSERT_NOT_NULL(with_def->fingerprint); + + double jaccard = + cbm_minhash_jaccard((const cbm_minhash_t *)without_def->fingerprint, + (const cbm_minhash_t *)with_def->fingerprint); + ASSERT_LT(jaccard, 1.0); + + cbm_free_result(without_result); + cbm_free_result(with_result); + free(src_without_suffix); + free(src_with_suffix); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Suite 2: Jaccard + LSH * ═══════════════════════════════════════════════════════════════════ */ @@ -551,6 +641,74 @@ TEST(lsh_index_build_and_query) { PASS(); } +TEST(lsh_query_into_reports_exact_partial_result) { + cbm_minhash_t fp; + for (int i = 0; i < CBM_MINHASH_K; i++) { + fp.values[i] = (uint32_t)(i * 17 + 3); + } + cbm_lsh_index_t *idx = cbm_lsh_new(); + ASSERT_NOT_NULL(idx); + for (int i = 0; i < 3; i++) { + cbm_lsh_entry_t entry = { + .node_id = i + 1, + .fingerprint = &fp, + .file_path = "partial.go", + .file_ext = ".go", + }; + cbm_lsh_insert(idx, &entry); + } + + const cbm_lsh_entry_t *out[2] = {NULL, NULL}; + cbm_lsh_query_result_t result = cbm_lsh_query_into_result(idx, &fp, out, 2); + ASSERT_EQ(result.written, 2); + ASSERT_EQ(result.omitted, 1); + ASSERT_EQ(result.noisy_buckets, 0); + ASSERT_FALSE(result.allocation_failed); + ASSERT_NOT_NULL(out[0]); + ASSERT_NOT_NULL(out[1]); + ASSERT_TRUE(out[0]->node_id != out[1]->node_id); + + cbm_lsh_entry_t sentinel = {0}; + const cbm_lsh_entry_t *compat_out[3] = {NULL, NULL, &sentinel}; + ASSERT_EQ(cbm_lsh_query_into(idx, &fp, compat_out, 2), 2); + ASSERT_NOT_NULL(compat_out[0]); + ASSERT_NOT_NULL(compat_out[1]); + ASSERT_TRUE(compat_out[2] == &sentinel); + + cbm_lsh_free(idx); + PASS(); +} + +TEST(lsh_query_into_reports_every_noisy_bucket) { + enum { LSH_NOISY_ENTRY_COUNT = CBM_LSH_MAX_BUCKET_SIZE + 1 }; + cbm_minhash_t fp; + for (int i = 0; i < CBM_MINHASH_K; i++) { + fp.values[i] = (uint32_t)(i * 19 + 5); + } + cbm_lsh_index_t *idx = cbm_lsh_new(); + ASSERT_NOT_NULL(idx); + for (int i = 0; i < LSH_NOISY_ENTRY_COUNT; i++) { + cbm_lsh_entry_t entry = { + .node_id = i + 1, + .fingerprint = &fp, + .file_path = "noisy.go", + .file_ext = ".go", + }; + cbm_lsh_insert(idx, &entry); + } + + const cbm_lsh_entry_t *out[1] = {NULL}; + cbm_lsh_query_result_t result = cbm_lsh_query_into_result(idx, &fp, out, 1); + ASSERT_EQ(result.written, 0); + ASSERT_EQ(result.omitted, 0); + ASSERT_EQ(result.noisy_buckets, CBM_LSH_BANDS); + ASSERT_FALSE(result.allocation_failed); + ASSERT_NULL(out[0]); + + cbm_lsh_free(idx); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Suite 3: Edge Generation (pass_similarity on graph buffer) * ═══════════════════════════════════════════════════════════════════ */ @@ -650,6 +808,7 @@ TEST(pass_similarity_same_file_tagged) { cbm_pipeline_pass_similarity(&ctx); + ASSERT_EQ(count_similar_to_edges(gb), 1); /* Pair ownership is canonical by qualified name (determinism fix): * "test.a.bar" < "test.a.foo", so bar owns the pair and is the source. */ const cbm_gbuf_edge_t **edges = NULL; @@ -1114,8 +1273,15 @@ TEST(pipeline_minhash_incremental_new_clone) { "}\n"); /* Step 3: Reindex (will be incremental if DB exists, or full) */ + cbm_config_t *cfg = cbm_config_open(g_sim_tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set( + cfg, CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH, + CBM_CONFIG_INCREMENTAL_DERIVED_RESULTS_REFRESH_AT_PUBLISH), + 0); cbm_pipeline_t *p2 = cbm_pipeline_new(g_sim_tmpdir, db_path, CBM_MODE_FULL); ASSERT_NOT_NULL(p2); + cbm_pipeline_apply_config(p2, cfg); rc = cbm_pipeline_run(p2); ASSERT_EQ(rc, 0); @@ -1130,6 +1296,7 @@ TEST(pipeline_minhash_incremental_new_clone) { } cbm_store_close(s2); cbm_pipeline_free(p2); + cbm_config_close(cfg); teardown_sim_test_repo(); PASS(); @@ -1147,6 +1314,7 @@ SUITE(simhash) { RUN_TEST(minhash_minor_edit_high_jaccard); RUN_TEST(minhash_empty_body_skipped); RUN_TEST(minhash_type_annotation_normalized); + RUN_TEST(minhash_reads_structure_after_former_token_prefix); /* Suite 2: Jaccard + LSH */ RUN_TEST(jaccard_identical); @@ -1156,6 +1324,8 @@ SUITE(simhash) { RUN_TEST(lsh_same_bucket_similar); RUN_TEST(lsh_different_bucket_dissimilar); RUN_TEST(lsh_index_build_and_query); + RUN_TEST(lsh_query_into_reports_exact_partial_result); + RUN_TEST(lsh_query_into_reports_every_noisy_bucket); /* Suite 3: Edge Generation */ RUN_TEST(pass_similarity_creates_edges); diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh index 111b5c7cd..97067324e 100755 --- a/tests/test_smoke_fixture_contract.sh +++ b/tests/test_smoke_fixture_contract.sh @@ -305,6 +305,24 @@ require( "PR Windows smoke must call vm-smoke.sh with SMOKE_ARCH=amd64", ) smoke_test = read("scripts/smoke-test.sh") +require( + 'get_graph_schema --project "$PROJECT" --format json' in smoke_test + and r'\"format\":\"json\"' in smoke_test, + "JSON-parsed schema assertions must request JSON independently of the configured default", +) +require( + r"rows\[[0-9][0-9]*\]" in smoke_test + and r"clusters\[\([0-9]*\)\]" in smoke_test + and r"semantic\[[0-9]+\]" in smoke_test, + "TOON assertions must recognize current table[N]{columns}: headers", +) +require( + "sleep 300 |" not in smoke_test + and 'mkfifo "$UI_INPUT"' in smoke_test + and 'exec 7>"$UI_INPUT"' in smoke_test + and "smoke_ui_stop" in smoke_test, + "Phase 15 must own and close its UI stdin FIFO instead of leaving a timer child", +) require( "MSYS2_ARG_CONV_EXCL='*'" in smoke_test and 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File' in smoke_test diff --git a/tests/test_soak_daemon_recovery_contract.sh b/tests/test_soak_daemon_recovery_contract.sh index 4b9444c7d..6f4e88ece 100644 --- a/tests/test_soak_daemon_recovery_contract.sh +++ b/tests/test_soak_daemon_recovery_contract.sh @@ -12,27 +12,33 @@ for required in \ 'json_rpc_response_ok()' \ 'and "result" in message' \ 'diagnostics_start_count()' \ - 'DAEMON_PID=$(diagnostics_json_value pid)' \ - 'Idle daemon CPU:' \ - 'SOAK_PROJECT_VALUE="$SOAK_PROJECT"' \ - 'SOAK_PROJECT_VALUE=$(cygpath -m "$SOAK_PROJECT")' \ + 'read_idle_cpu_sample()' \ + 'user_cpu_ms = d.get("process_user_cpu_ms")' \ + 'system_cpu_ms = d.get("process_system_cpu_ms")' \ + 'Idle CPU: ${IDLE_CPU}% over ${IDLE_OBSERVED_SECONDS}s' \ + 'MCP_SOAK_PROJECT="$SOAK_PROJECT"' \ + 'MCP_SOAK_PROJECT=$(cygpath -m "$SOAK_PROJECT")' \ 'SOAK_PROJECT_JSON=$(python3 -c' \ 'mcp_response_project()' \ - 'PROJ_NAME=$(mcp_response_project "$MCP_LAST_RESPONSE")' \ + 'PROJ_NAME=$(mcp_response_project "$LAST_MCP_RESPONSE")' \ 'FAIL: soak DACL normalize' \ 'FAIL: soak DACL stamp' \ 'FAIL: soak child DACL reset' \ 'SOAK_NATIVE_WINDOWS=false' \ "eval 'coproc CBM_SOAK_SERVER {" \ 'SERVER_PID=$CBM_SOAK_SERVER_PID' \ - 'start_mcp_server truncate' \ - 'start_mcp_server append' \ + 'CBM_AUTO_INDEX=false' \ + 'FDS_OPEN=true' \ + 'stderr_mode="truncate"' \ + 'stderr_mode="append"' \ + 'start_mcp_server "$stderr_mode"' \ 'def handle_${i}(request):' \ 'trace_path "{\"project\":\"$PROJ_NAME\",\"function_name\":\"handle_1\",\"direction\":\"both\"}"' \ - 'wait_for_daemon_stop "$DAEMON_STOP_COUNT"' \ - 'wait_for_daemon_stop "$FINAL_DAEMON_STOP_COUNT"' \ - 'wait_for_diagnostics_snapshot "$DIAGNOSTICS_START_COUNT" "$DIAG_FILE_BEFORE_CRASH"' \ - 'mcp_call index_repository "{\"repo_path\":$SOAK_PROJECT_JSON}" || PASS=false'; do + 'wait_for_daemon_stop "${DAEMON_STOP_COUNT:-0}"' \ + 'wait_for_daemon_stop "${FINAL_DAEMON_STOP_COUNT:-0}"' \ + 'wait_for_diagnostics_snapshot "${snapshots_before:-0}" "$previous_snapshot"' \ + 'start_server "$DIAG_FILE_BEFORE_CRASH"' \ + 'if mcp_call index_repository "{\"repo_path\":$SOAK_PROJECT_JSON}"; then'; do if ! grep -Fq "$required" "$soak"; then echo "FAIL: daemon soak recovery contract missing: $required" >&2 exit 1 @@ -60,6 +66,26 @@ if grep -Fq 'ps -o %cpu= -p "$SERVER_PID"' "$soak"; then exit 1 fi +if grep -Fq 'diagnostics_json_value()' "$soak" || grep -Fq 'DAEMON_PID=' "$soak"; then + echo "FAIL: soak idle CPU must use daemon process-time deltas, not a sampled pid" >&2 + exit 1 +fi + +if grep -Fq 'mktemp -u' "$soak"; then + echo "FAIL: soak transport endpoints must stay under the owned private root" >&2 + exit 1 +fi + +if grep -Fq 'SOAK_CACHE_DIR_HOST' "$soak" || grep -Fq 'SOAK_WIN_ROOT' "$soak"; then + echo "FAIL: soak must not split runtime ownership across independent temporary roots" >&2 + exit 1 +fi + +if ! grep -Fq 'rm -rf -- "$SOAK_ROOT"' "$soak"; then + echo "FAIL: soak cleanup must remove the one root that owns every runtime artifact" >&2 + exit 1 +fi + if [ "$(grep -c '^PASS=true$' "$soak")" -ne 1 ]; then echo "FAIL: soak result state must be initialized exactly once" >&2 exit 1 diff --git a/tests/test_sqlite_helpers.h b/tests/test_sqlite_helpers.h new file mode 100644 index 000000000..d9b593f0b --- /dev/null +++ b/tests/test_sqlite_helpers.h @@ -0,0 +1,22 @@ +#ifndef TEST_SQLITE_HELPERS_H +#define TEST_SQLITE_HELPERS_H + +#include "sqlite3.h" + +static inline int cbm_test_sqlite_object_exists(sqlite3 *db, const char *type, const char *name) { + sqlite3_stmt *stmt = NULL; + int exists = 0; + if (!db || !type || !name || + sqlite3_prepare_v2(db, + "SELECT 1 FROM sqlite_master WHERE type = ?1 AND name = ?2 LIMIT 1", + -1, &stmt, NULL) != SQLITE_OK) { + return 0; + } + sqlite3_bind_text(stmt, 1, type, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, name, -1, SQLITE_STATIC); + exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +#endif diff --git a/tests/test_sqlite_writer.c b/tests/test_sqlite_writer.c index 1084d0529..2c76ec923 100644 --- a/tests/test_sqlite_writer.c +++ b/tests/test_sqlite_writer.c @@ -8,11 +8,18 @@ * bypassing the SQL parser entirely. These tests verify integrity. */ #include "../src/foundation/compat.h" -#include "foundation/compat_fs.h" +#include "../src/foundation/compat_fs.h" +#include "../src/foundation/compat_thread.h" +#include "../src/foundation/constants.h" #include "test_framework.h" +#include "test_sqlite_helpers.h" +#include /* sqlite_writer.h is at internal/cbm/ — Makefile adds -Iinternal/cbm */ #include "sqlite_writer.h" /* CBMDumpNode, CBMDumpEdge, cbm_write_db */ #include "sqlite3.h" /* vendored/sqlite3/ via -Ivendored/sqlite3 */ +#include +#include +#include #include /* ── Helper: create temp file path ─────────────────────────────── */ @@ -83,24 +90,83 @@ static int count_temp_outputs_for(const char *path) { snprintf(base, sizeof(base), "%s", path); } - cbm_dir_t *d = cbm_opendir(dir); - if (!d) { + cbm_dir_t *directory = cbm_opendir(dir); + if (!directory) { return -1; } size_t base_len = strlen(base); int count = 0; - cbm_dirent_t *ent; - while ((ent = cbm_readdir(d)) != NULL) { - size_t name_len = strlen(ent->name); - if (name_len > base_len + 5 && strncmp(ent->name, base, base_len) == 0 && - strncmp(ent->name + base_len, ".tmp.", 5) == 0) { + cbm_dirent_t *entry; + while ((entry = cbm_readdir(directory)) != NULL) { + size_t name_len = strlen(entry->name); + if (name_len > base_len + 5 && strncmp(entry->name, base, base_len) == 0 && + strncmp(entry->name + base_len, ".tmp.", 5) == 0) { count++; } } - cbm_closedir(d); + cbm_closedir(directory); return count; } +static int verify_writer_db(const char *path, const char *project, const char *root_path, + int expected_nodes, int expected_edges) { + sqlite3 *db = NULL; + if (sqlite3_open(path, &db) != SQLITE_OK) { + return CBM_NOT_FOUND; + } + + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, "PRAGMA integrity_check", -1, &stmt, NULL) != SQLITE_OK || + sqlite3_step(stmt) != SQLITE_ROW || + strcmp((const char *)sqlite3_column_text(stmt, 0), "ok") != 0) { + if (stmt) { + sqlite3_finalize(stmt); + } + sqlite3_close(db); + return CBM_NOT_FOUND; + } + sqlite3_finalize(stmt); + stmt = NULL; + + if (sqlite3_prepare_v2(db, "SELECT root_path FROM projects WHERE name=?1", -1, &stmt, NULL) != + SQLITE_OK) { + sqlite3_close(db); + return CBM_NOT_FOUND; + } + sqlite3_bind_text(stmt, 1, project, -1, SQLITE_STATIC); + if (sqlite3_step(stmt) != SQLITE_ROW || + strcmp((const char *)sqlite3_column_text(stmt, 0), root_path) != 0) { + sqlite3_finalize(stmt); + sqlite3_close(db); + return CBM_NOT_FOUND; + } + sqlite3_finalize(stmt); + stmt = NULL; + + if (sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM nodes", -1, &stmt, NULL) != SQLITE_OK || + sqlite3_step(stmt) != SQLITE_ROW || sqlite3_column_int(stmt, 0) != expected_nodes) { + if (stmt) { + sqlite3_finalize(stmt); + } + sqlite3_close(db); + return CBM_NOT_FOUND; + } + sqlite3_finalize(stmt); + stmt = NULL; + + if (sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM edges", -1, &stmt, NULL) != SQLITE_OK || + sqlite3_step(stmt) != SQLITE_ROW || sqlite3_column_int(stmt, 0) != expected_edges) { + if (stmt) { + sqlite3_finalize(stmt); + } + sqlite3_close(db); + return CBM_NOT_FOUND; + } + sqlite3_finalize(stmt); + sqlite3_close(db); + return 0; +} + /* ── Tests ─────────────────────────────────────────────────────── */ TEST(sw_minimal_data) { @@ -193,7 +259,44 @@ TEST(sw_minimal_data) { sqlite3_finalize(stmt); sqlite3_close(db); - unlink(path); + cbm_unlink(path); + PASS(); +} + +TEST(sw_store_open_migrates_exact_delta_metadata) { + char path[CBM_SZ_256]; + ASSERT_EQ(make_temp_db(path, sizeof(path)), 0); + + CBMDumpNode nodes[1] = { + {.id = 1, + .project = "test", + .label = "Module", + .name = "main", + .qualified_name = "test.main", + .file_path = "main.go", + .start_line = 1, + .end_line = 1, + .properties = "{}"}, + }; + + int rc = cbm_write_db(path, "test", "/tmp/test", "2026-03-14T00:00:00Z", nodes, 1, NULL, 0, + NULL, 0, NULL, 0); + ASSERT_EQ(rc, 0); + + sqlite3 *raw = NULL; + ASSERT_EQ(sqlite3_open(path, &raw), SQLITE_OK); + ASSERT_FALSE(cbm_test_sqlite_object_exists(raw, "table", "file_state")); + sqlite3_close(raw); + + cbm_store_t *store = cbm_store_open_path(path); + ASSERT_NOT_NULL(store); + sqlite3 *db = cbm_store_get_db(store); + ASSERT_TRUE(cbm_test_sqlite_object_exists(db, "table", "file_state")); + ASSERT_TRUE(cbm_test_sqlite_object_exists(db, "table", "node_owners")); + ASSERT_TRUE(cbm_test_sqlite_object_exists(db, "index", "idx_node_owners_path")); + cbm_store_close(store); + + cbm_unlink(path); PASS(); } @@ -437,7 +540,7 @@ TEST(sw_scale_and_indexes) { sqlite3_finalize(stmt); sqlite3_close(db); - unlink(path); + cbm_unlink(path); PASS(); } @@ -513,7 +616,7 @@ TEST(sw_long_index_keys_overflow) { free(longname); free(longqn); - unlink(path); + cbm_unlink(path); PASS(); } @@ -536,7 +639,83 @@ TEST(sw_empty) { sqlite3_finalize(stmt); sqlite3_close(db); - unlink(path); + cbm_unlink(path); + PASS(); +} + +TEST(sw_vectors_and_token_vectors) { + char path[256]; + ASSERT_EQ(make_temp_db(path, sizeof(path)), 0); + + CBMDumpNode nodes[2] = { + {.id = 1, + .project = "test", + .label = "Function", + .name = "source", + .qualified_name = "test.source", + .file_path = "main.py", + .start_line = 1, + .end_line = 3, + .properties = "{}"}, + {.id = 2, + .project = "test", + .label = "Function", + .name = "target", + .qualified_name = "test.target", + .file_path = "main.py", + .start_line = 5, + .end_line = 8, + .properties = "{}"}, + }; + CBMDumpEdge edges[1] = { + {.id = 1, + .project = "test", + .source_id = 1, + .target_id = 2, + .type = "SEMANTICALLY_RELATED", + .properties = "{\"score\":0.75}", + .url_path = ""}, + }; + static const uint8_t node_vec[] = {1, 2, 3, 4}; + static const uint8_t token_vec[] = {5, 6, 7, 8, 9}; + CBMDumpVector vectors[1] = { + {.node_id = 1, .project = "test", .vector = node_vec, .vector_len = sizeof(node_vec)}, + }; + CBMDumpTokenVec token_vecs[1] = { + {.id = 1, .project = "test", .token = "source", .vector = token_vec, + .vector_len = sizeof(token_vec), .idf = 1.25f}, + }; + + int rc = cbm_write_db(path, "test", "/tmp/test", "2026-03-14T00:00:00Z", nodes, 2, edges, 1, + vectors, 1, token_vecs, 1); + ASSERT_EQ(rc, 0); + + sqlite3 *db = NULL; + ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK); + sqlite3_stmt *stmt = NULL; + + sqlite3_prepare_v2(db, "PRAGMA integrity_check", -1, &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "ok"); + sqlite3_finalize(stmt); + + sqlite3_prepare_v2(db, "SELECT length(vector) FROM node_vectors WHERE node_id=1", -1, &stmt, + NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_EQ(sqlite3_column_int(stmt, 0), (int)sizeof(node_vec)); + sqlite3_finalize(stmt); + + sqlite3_prepare_v2(db, "SELECT token, length(vector), idf FROM token_vectors WHERE id=1", -1, + &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "source"); + ASSERT_EQ(sqlite3_column_int(stmt, 1), (int)sizeof(token_vec)); + enum { TEST_IDF_FIXED_POINT = 1250 }; + ASSERT_EQ(sqlite3_column_int(stmt, 2), TEST_IDF_FIXED_POINT); + sqlite3_finalize(stmt); + + sqlite3_close(db); + cbm_unlink(path); PASS(); } @@ -613,7 +792,7 @@ TEST(sw_multi_page) { sqlite3_finalize(stmt); sqlite3_close(db); - unlink(path); + cbm_unlink(path); PASS(); } @@ -677,7 +856,7 @@ TEST(sw_oversized_node) { sqlite3_finalize(stmt); sqlite3_close(db); - unlink(path); + cbm_unlink(path); PASS(); } @@ -877,17 +1056,230 @@ TEST(sw_publish_preserves_live_reader) { /* ── Suite ─────────────────────────────────────────────────────── */ +/* B1 (#8) repro probe: the full pipeline intermittently stores a NUMERIC + * root_path + wildly varying edge counts (43K vs 275K) on the ~16K-node + * fastapi repo, while sw_minimal_data (tiny) round-trips cleanly. This test + * writes a comparable-scale DB in ISOLATION (no pipeline/parallelism) and + * verifies root_path round-trips exactly + integrity_check stays "ok". If this + * fails, the writer itself corrupts at scale (directly debuggable); if it + * passes, the B1 corruption is pipeline/parallel-side, not the writer. */ +TEST(sw_scale_root_path_integrity) { + char path[256]; + ASSERT_EQ(make_temp_db(path, sizeof(path)), 0); + + const int N = 20000; + const int E = 200000; + CBMDumpNode *nodes = (CBMDumpNode *)calloc((size_t)N, sizeof(CBMDumpNode)); + CBMDumpEdge *edges = (CBMDumpEdge *)calloc((size_t)E, sizeof(CBMDumpEdge)); + char (*namebuf)[32] = malloc((size_t)N * 32); + char (*qnbuf)[64] = malloc((size_t)N * 64); + char (*filebuf)[48] = malloc((size_t)N * 48); + char (*propsbuf)[2048] = malloc((size_t)N * 2048); + ASSERT_NOT_NULL(nodes); + ASSERT_NOT_NULL(edges); + ASSERT_NOT_NULL(namebuf); + ASSERT_NOT_NULL(qnbuf); + ASSERT_NOT_NULL(filebuf); + ASSERT_NOT_NULL(propsbuf); + + for (int i = 0; i < N; i++) { + snprintf(namebuf[i], 32, "fn_%d", i); + snprintf(qnbuf[i], 64, "proj.mod.fn_%d", i); + snprintf(filebuf[i], 48, "src/file_%d.py", i % 400); + nodes[i].id = i + 1; + nodes[i].project = "proj"; + nodes[i].label = "Function"; + nodes[i].name = namebuf[i]; + nodes[i].qualified_name = qnbuf[i]; + nodes[i].file_path = filebuf[i]; + nodes[i].start_line = i + 1; + nodes[i].end_line = i + 2; + /* Variable-length properties (mirrors real data) to stress page + * boundaries in the writer — the B1 trigger hypothesis (uniform + * records never cross boundaries the way real variable records do). */ + static const int plens[] = {20, 200, 800, 1500, 50, 400, 1000, 100}; + int padlen = plens[i % 8] - 8; /* {"k":""} overhead */ + if (padlen < 0) padlen = 0; + if (padlen > 2040) padlen = 2040; + propsbuf[i][0] = '{'; + propsbuf[i][1] = '"'; + propsbuf[i][2] = 'k'; + propsbuf[i][3] = '"'; + propsbuf[i][4] = ':'; + propsbuf[i][5] = '"'; + memset(propsbuf[i] + 6, 'y', (size_t)padlen); + propsbuf[i][6 + padlen] = '"'; + propsbuf[i][6 + padlen + 1] = '}'; + propsbuf[i][6 + padlen + 2] = '\0'; + nodes[i].properties = propsbuf[i]; + } + for (int i = 0; i < E; i++) { + edges[i].id = i + 1; + edges[i].project = "proj"; + /* edges has UNIQUE(source_id, target_id, type) — generate distinct + * (source,target) pairs so the test exercises the writer, not the + * constraint: source cycles 1..N, target = block (i/N), giving E unique + * pairs for E <= N*N. */ + edges[i].source_id = (i % N) + 1; + edges[i].target_id = ((i / N) % N) + 1; + edges[i].type = "CALLS"; + edges[i].properties = "{}"; + edges[i].url_path = ""; + } + + const char *ROOT = "/tmp/scale_root_path_test"; + int rc = cbm_write_db(path, "proj", ROOT, "2026-06-25T00:00:00Z", nodes, N, edges, E, NULL, 0, + NULL, 0); + ASSERT_EQ(rc, 0); + + sqlite3 *db = NULL; + ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK); + sqlite3_stmt *stmt = NULL; + + sqlite3_prepare_v2(db, "PRAGMA integrity_check", -1, &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "ok"); + sqlite3_finalize(stmt); + + /* root_path MUST round-trip exactly — B1 reproduces as a numeric value. */ + sqlite3_prepare_v2(db, "SELECT root_path FROM projects", -1, &stmt, NULL); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), ROOT); + sqlite3_finalize(stmt); + + sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM nodes", -1, &stmt, NULL); + sqlite3_step(stmt); + ASSERT_EQ(sqlite3_column_int(stmt, 0), N); + sqlite3_finalize(stmt); + + sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM edges", -1, &stmt, NULL); + sqlite3_step(stmt); + ASSERT_EQ(sqlite3_column_int(stmt, 0), E); + sqlite3_finalize(stmt); + + sqlite3_close(db); + cbm_unlink(path); + free(nodes); + free(edges); + free(namebuf); + free(qnbuf); + free(filebuf); + free(propsbuf); + PASS(); +} + +typedef struct { + char path[CBM_PATH_MAX]; + char project[CBM_SZ_32]; + char root_path[CBM_PATH_MAX]; + atomic_int *start; + int node_count; + int edge_count; + int rc; +} sw_concurrent_job_t; + +static void *sw_concurrent_writer_thread(void *arg) { + sw_concurrent_job_t *job = (sw_concurrent_job_t *)arg; + while (atomic_load(job->start) == 0) { + } + + CBMDumpNode *nodes = (CBMDumpNode *)calloc((size_t)job->node_count, sizeof(*nodes)); + CBMDumpEdge *edges = (CBMDumpEdge *)calloc((size_t)job->edge_count, sizeof(*edges)); + char (*names)[CBM_SZ_32] = malloc((size_t)job->node_count * CBM_SZ_32); + char (*qns)[CBM_SZ_64] = malloc((size_t)job->node_count * CBM_SZ_64); + char (*files)[CBM_SZ_32] = malloc((size_t)job->node_count * CBM_SZ_32); + if (!nodes || !edges || !names || !qns || !files) { + free(nodes); + free(edges); + free(names); + free(qns); + free(files); + job->rc = CBM_NOT_FOUND; + return NULL; + } + + for (int i = 0; i < job->node_count; i++) { + snprintf(names[i], CBM_SZ_32, "fn_%04d", i); + snprintf(qns[i], CBM_SZ_64, "%s.mod.fn_%04d", job->project, i); + snprintf(files[i], CBM_SZ_32, "src/file_%03d.py", i % CBM_SZ_128); + nodes[i].id = i + 1; + nodes[i].project = job->project; + nodes[i].label = (i % PAIR_LEN) == 0 ? "Function" : "Class"; + nodes[i].name = names[i]; + nodes[i].qualified_name = qns[i]; + nodes[i].file_path = files[i]; + nodes[i].start_line = i + SKIP_ONE; + nodes[i].end_line = i + PAIR_LEN; + nodes[i].properties = "{}"; + } + for (int i = 0; i < job->edge_count; i++) { + edges[i].id = i + 1; + edges[i].project = job->project; + edges[i].source_id = (i % job->node_count) + 1; + edges[i].target_id = ((i / job->node_count) % job->node_count) + 1; + edges[i].type = "CALLS"; + edges[i].properties = "{}"; + edges[i].url_path = ""; + } + + job->rc = cbm_write_db(job->path, job->project, job->root_path, "2026-06-30T00:00:00Z", + nodes, job->node_count, edges, job->edge_count, NULL, 0, NULL, 0); + free(nodes); + free(edges); + free(names); + free(qns); + free(files); + return NULL; +} + +TEST(sw_concurrent_writes_are_independent) { + enum { + CONCURRENT_WRITERS = PAIR_LEN, + CONCURRENT_NODES = CBM_SZ_1K, + CONCURRENT_EDGES = CBM_SZ_4K, + }; + atomic_int start = 0; + sw_concurrent_job_t jobs[CONCURRENT_WRITERS] = {0}; + cbm_thread_t threads[CONCURRENT_WRITERS]; + + for (int i = 0; i < CONCURRENT_WRITERS; i++) { + ASSERT_EQ(make_temp_db(jobs[i].path, sizeof(jobs[i].path)), 0); + snprintf(jobs[i].project, sizeof(jobs[i].project), "proj%d", i); + snprintf(jobs[i].root_path, sizeof(jobs[i].root_path), "/tmp/sw_concurrent_root_%d", i); + jobs[i].start = &start; + jobs[i].node_count = CONCURRENT_NODES; + jobs[i].edge_count = CONCURRENT_EDGES; + jobs[i].rc = CBM_NOT_FOUND; + ASSERT_EQ(cbm_thread_create(&threads[i], 0, sw_concurrent_writer_thread, &jobs[i]), 0); + } + + atomic_store(&start, CBM_INIT_DONE); + for (int i = 0; i < CONCURRENT_WRITERS; i++) { + ASSERT_EQ(cbm_thread_join(&threads[i]), 0); + ASSERT_EQ(jobs[i].rc, 0); + ASSERT_EQ(verify_writer_db(jobs[i].path, jobs[i].project, jobs[i].root_path, + jobs[i].node_count, jobs[i].edge_count), + 0); + cbm_unlink(jobs[i].path); + } + PASS(); +} + SUITE(sqlite_writer) { RUN_TEST(sw_minimal_data); + RUN_TEST(sw_store_open_migrates_exact_delta_metadata); RUN_TEST(sw_imports_local_name_unique); RUN_TEST(sw_scale_and_indexes); RUN_TEST(sw_long_index_keys_overflow); RUN_TEST(sw_empty); + RUN_TEST(sw_vectors_and_token_vectors); RUN_TEST(sw_multi_page); RUN_TEST(sw_oversized_node); + RUN_TEST(sw_scale_root_path_integrity); RUN_TEST(sw_stream_open_does_not_truncate_destination); RUN_TEST(sw_publish_removes_destination_sidecars); RUN_TEST(sw_publish_failure_preserves_destination_sidecars); RUN_TEST(sw_publish_supports_non_ascii_path); RUN_TEST(sw_publish_preserves_live_reader); + RUN_TEST(sw_concurrent_writes_are_independent); } diff --git a/tests/test_stack_overflow.c b/tests/test_stack_overflow.c index 6b2e8228c..683e2ff05 100644 --- a/tests/test_stack_overflow.c +++ b/tests/test_stack_overflow.c @@ -16,6 +16,7 @@ #include #include #include +#include /* tree-sitter runtime allocator hooks (ts_runtime/src/alloc.h, TS_PUBLIC) and * mimalloc (vendored) — for the #424 allocator-binding regression test. */ @@ -436,7 +437,16 @@ static bool so_extract_crashes(const char *content, CBMLanguage lang, const char _exit(0); } int status = 0; - (void)waitpid(pid, &status, 0); + /* leaks --atExit (macOS) SIGSTOPs the forked child during heap inspection; + * WUNTRACED+SIGCONT avoids the hang (mirrors test_store_bulk.c, b336466). */ + for (;;) { + if (waitpid(pid, &status, WUNTRACED) < 0) break; + if (WIFSTOPPED(status)) { + kill(pid, SIGCONT); + continue; + } + break; + } return WIFSIGNALED(status); #endif } diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 159c07f95..6bdaf6816 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -21,12 +21,28 @@ * TestIsTestFilePath */ #include "test_framework.h" +#include #include +#include #include #include #include #include +enum { + TEST_ARCH_PATH_BUF = 512, + TEST_ARCH_LONG_PATH_BUF = 1024, + TEST_ARCH_NO_COMMUNITY = -1, + TEST_ARCH_FALLBACK_DISTINCT_PACKAGES = 64, + TEST_ARCH_FALLBACK_WINNER_NODES = 20, + TEST_ARCH_FALLBACK_NAME_BUF = 64, + TEST_ARCH_FILE_TREE_DISTINCT_DIRS = 70, + TEST_ARCH_FILE_TREE_LONG_COMPONENT = 600, + TEST_ARCH_CLUSTER_BUDGET_NODES = CBM_DEFAULT_ARCH_CLUSTER_NODE_BUDGET + 1, + TEST_ARCH_CLUSTER_DISTRACTOR_CONTEXTS = 5, + TEST_ARCH_CLUSTER_WINNER_MEMBERS = 10 +}; + /* ── Helper: create architecture test store ──────────────────────── */ static cbm_store_t *setup_arch_test_store(void) { @@ -143,7 +159,7 @@ static cbm_store_t *setup_arch_test_store(void) { TEST(arch_get_all) { cbm_store_t *s = setup_arch_test_store(); cbm_architecture_info_t info; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, NULL, 0, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, 0, &info, 0, 1.0), CBM_STORE_OK); ASSERT_TRUE(info.language_count > 0); ASSERT_TRUE(info.package_count > 0); @@ -157,51 +173,58 @@ TEST(arch_get_all) { PASS(); } -TEST(arch_entry_points_exclude_tests) { - cbm_store_t *s = setup_arch_test_store(); - cbm_architecture_info_t info; - memset(&info, 0, sizeof(info)); - const char *aspects[] = {"entry_points"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); +TEST(arch_package_fallback_ranks_all_qualified_names_before_preview) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "package-fallback", "/tmp/package-fallback"), + CBM_STORE_OK); - for (int i = 0; i < info.entry_point_count; i++) { - ASSERT_TRUE(strstr(info.entry_points[i].file, "test") == NULL); + /* Fill the former fixed working set with singleton packages first. */ + for (int i = 0; i < TEST_ARCH_FALLBACK_DISTINCT_PACKAGES; i++) { + char name[TEST_ARCH_FALLBACK_NAME_BUF]; + char qn[TEST_ARCH_FALLBACK_NAME_BUF]; + ASSERT_TRUE(snprintf(name, sizeof(name), "singleton%03d", i) > 0); + ASSERT_TRUE(snprintf(qn, sizeof(qn), "package-fallback.root.pkg%03d.%s", i, name) > 0); + cbm_node_t node = { + .project = "package-fallback", .label = "Function", .name = name, .qualified_name = qn}; + ASSERT_TRUE(cbm_store_upsert_node(s, &node) > 0); } - ASSERT_EQ(info.entry_point_count, 2); /* main, HandleRequest */ - cbm_store_architecture_free(&info); - cbm_store_close(s); - PASS(); -} - -TEST(arch_hotspots_exclude_tests) { - cbm_store_t *s = setup_arch_test_store(); - cbm_architecture_info_t info; - memset(&info, 0, sizeof(info)); - const char *aspects[] = {"hotspots"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); - - for (int i = 0; i < info.hotspot_count; i++) { - ASSERT_TRUE(strstr(info.hotspots[i].name, "Test") == NULL); + /* A later package must still win the exact count-based ranking. */ + for (int i = 0; i < TEST_ARCH_FALLBACK_WINNER_NODES; i++) { + char name[TEST_ARCH_FALLBACK_NAME_BUF]; + char qn[TEST_ARCH_FALLBACK_NAME_BUF]; + ASSERT_TRUE(snprintf(name, sizeof(name), "winner%03d", i) > 0); + ASSERT_TRUE(snprintf(qn, sizeof(qn), "package-fallback.root.winner.%s", name) > 0); + cbm_node_t node = { + .project = "package-fallback", .label = "Function", .name = name, .qualified_name = qn}; + ASSERT_TRUE(cbm_store_upsert_node(s, &node) > 0); } + const char *aspects[] = {"packages"}; + cbm_architecture_info_t info = {0}; + ASSERT_EQ(cbm_store_get_architecture(s, "package-fallback", aspects, 1, &info, 0, 1.0), + CBM_STORE_OK); + ASSERT_TRUE(info.package_count > 0); + ASSERT_STR_EQ(info.packages[0].name, "winner"); + ASSERT_EQ(info.packages[0].node_count, TEST_ARCH_FALLBACK_WINNER_NODES); + cbm_store_architecture_free(&info); cbm_store_close(s); PASS(); } -TEST(arch_specific_aspects) { +TEST(arch_entry_points_exclude_tests) { cbm_store_t *s = setup_arch_test_store(); cbm_architecture_info_t info; - const char *aspects[] = {"languages", "hotspots"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 2, &info), CBM_STORE_OK); + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"entry_points"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); - ASSERT_TRUE(info.language_count > 0); - ASSERT_TRUE(info.hotspot_count > 0); - /* Not requested: should be zero */ - ASSERT_EQ(info.package_count, 0); - ASSERT_EQ(info.entry_point_count, 0); - ASSERT_EQ(info.route_count, 0); + for (int i = 0; i < info.entry_point_count; i++) { + ASSERT_TRUE(strstr(info.entry_points[i].file, "test") == NULL); + } + ASSERT_EQ(info.entry_point_count, 2); /* main, HandleRequest */ cbm_store_architecture_free(&info); cbm_store_close(s); @@ -240,13 +263,13 @@ TEST(arch_path_scoping) { cbm_store_upsert_node(s, &fn_other); const char *aspects[] = {"languages", "packages"}; - cbm_architecture_info_t whole; - memset(&whole, 0, sizeof(whole)); - ASSERT_EQ(cbm_store_get_architecture(s, "pscope", NULL, aspects, 2, &whole), CBM_STORE_OK); + cbm_architecture_info_t whole = {0}; + ASSERT_EQ(cbm_store_get_architecture(s, "pscope", aspects, 2, &whole, 0, 1.0), + CBM_STORE_OK); - cbm_architecture_info_t scoped; - memset(&scoped, 0, sizeof(scoped)); - ASSERT_EQ(cbm_store_get_architecture(s, "pscope", "apps/foo", aspects, 2, &scoped), + cbm_architecture_info_t scoped = {0}; + ASSERT_EQ(cbm_store_get_architecture_scoped(s, "pscope", "apps/foo", aspects, 2, &scoped, + 0, 1.0), CBM_STORE_OK); int whole_go = 0; @@ -274,12 +297,15 @@ TEST(arch_path_scoping) { } ASSERT_TRUE(whole_pkg_nodes > scoped_pkg_nodes); ASSERT_EQ(scoped_pkg_nodes, 1); - - ASSERT_TRUE(cbm_store_count_nodes(s, "pscope") > cbm_store_count_nodes_scoped(s, "pscope", "apps/foo")); - - cbm_architecture_info_t scoped_slash; - memset(&scoped_slash, 0, sizeof(scoped_slash)); - ASSERT_EQ(cbm_store_get_architecture(s, "pscope", "apps/foo/", aspects, 2, &scoped_slash), + ASSERT_TRUE(cbm_store_count_nodes(s, "pscope") > + cbm_store_count_nodes_scoped(s, "pscope", "apps/foo")); + char norm_path[TEST_ARCH_PATH_BUF]; + ASSERT_TRUE(cbm_store_normalize_arch_path(".\\apps\\foo\\", norm_path, sizeof(norm_path))); + ASSERT_STR_EQ(norm_path, "apps/foo"); + + cbm_architecture_info_t scoped_slash = {0}; + ASSERT_EQ(cbm_store_get_architecture_scoped(s, "pscope", "apps/foo/", aspects, 2, + &scoped_slash, 0, 1.0), CBM_STORE_OK); int slash_go = 0; for (int i = 0; i < scoped_slash.language_count; i++) { @@ -296,6 +322,40 @@ TEST(arch_path_scoping) { PASS(); } +TEST(arch_hotspots_exclude_tests) { + cbm_store_t *s = setup_arch_test_store(); + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"hotspots"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + for (int i = 0; i < info.hotspot_count; i++) { + ASSERT_TRUE(strstr(info.hotspots[i].name, "Test") == NULL); + } + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + +TEST(arch_specific_aspects) { + cbm_store_t *s = setup_arch_test_store(); + cbm_architecture_info_t info; + const char *aspects[] = {"languages", "hotspots"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 2, &info, 0, 1.0), CBM_STORE_OK); + + ASSERT_TRUE(info.language_count > 0); + ASSERT_TRUE(info.hotspot_count > 0); + /* Not requested: should be zero */ + ASSERT_EQ(info.package_count, 0); + ASSERT_EQ(info.entry_point_count, 0); + ASSERT_EQ(info.route_count, 0); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + TEST(arch_empty_project) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -303,7 +363,7 @@ TEST(arch_empty_project) { cbm_architecture_info_t info; const char *aspects[] = {"all"}; - ASSERT_EQ(cbm_store_get_architecture(s, "empty", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "empty", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); /* All should be empty but no errors */ cbm_store_architecture_free(&info); @@ -316,7 +376,7 @@ TEST(arch_languages) { cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"languages"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); /* Check Go=3, Python=1, JavaScript=1 */ int go_count = 0, py_count = 0, js_count = 0; @@ -337,20 +397,158 @@ TEST(arch_languages) { PASS(); } +TEST(arch_file_summaries_use_overlay_active_tombstones) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "overlay-files", "/tmp/overlay-files"), CBM_STORE_OK); + + cbm_node_t stale_file = {.project = "overlay-files", + .label = "File", + .name = "stale.py", + .qualified_name = "overlay-files.src.stale", + .file_path = "src/stale.py", + .properties_json = "{}"}; + cbm_node_t live_file = {.project = "overlay-files", + .label = "File", + .name = "live.go", + .qualified_name = "overlay-files.src.live", + .file_path = "src/live.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &stale_file), 0); + ASSERT_GT(cbm_store_upsert_node(s, &live_file), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "overlay-files", 1, &overlay_generation), + CBM_STORE_OK); + cbm_store_file_delta_t delete_delta = {.project = "overlay-files", + .rel_path = "src/stale.py", + .generation = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delete_delta, overlay_generation), + CBM_STORE_OK); + + const char *aspects[] = {"languages", "file_tree"}; + cbm_architecture_info_t info = {0}; + ASSERT_EQ(cbm_store_get_architecture_scoped(s, "overlay-files", "src", aspects, 2, &info, 0, + 1.0), + CBM_STORE_OK); + + int go_count = 0; + int py_count = 0; + for (int i = 0; i < info.language_count; i++) { + if (strcmp(info.languages[i].language, "Go") == 0) { + go_count = info.languages[i].file_count; + } + if (strcmp(info.languages[i].language, "Python") == 0) { + py_count = info.languages[i].file_count; + } + } + ASSERT_EQ(go_count, 1); + ASSERT_EQ(py_count, 0); + + bool saw_live = false; + bool saw_stale = false; + for (int i = 0; i < info.file_tree_count; i++) { + if (strcmp(info.file_tree[i].path, "src/live.go") == 0) { + saw_live = true; + } + if (strcmp(info.file_tree[i].path, "src/stale.py") == 0) { + saw_stale = true; + } + } + ASSERT_TRUE(saw_live); + ASSERT_TRUE(!saw_stale); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + TEST(arch_routes) { cbm_store_t *s = setup_arch_test_store(); + cbm_node_t infra_url = { + .project = "test", + .label = "Route", + .name = "https://example.com/api/orders", + .qualified_name = "__route__infra__https://example.com/api/orders", + .properties_json = "{\"source\":\"infra\",\"key_path\":\"ServiceUrl\"}"}; + cbm_store_upsert_node(s, &infra_url); + cbm_node_t code_url = { + .project = "test", + .label = "Route", + .name = "https://api.example.com/v1/orders", + .qualified_name = "__route__GET__https://api.example.com/v1/orders", + .properties_json = + "{\"method\":\"GET\",\"path\":\"https://api.example.com/v1/orders\"}"}; + cbm_store_upsert_node(s, &code_url); + cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"routes"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + ASSERT_EQ(info.route_count, 2); + bool saw_app_route = false; + bool saw_code_url = false; + for (int i = 0; i < info.route_count; i++) { + if (strcmp(info.routes[i].path, "/api/orders") == 0) { + saw_app_route = true; + ASSERT_STR_EQ(info.routes[i].method, "POST"); + ASSERT_STR_EQ(info.routes[i].handler, "HandleRequest"); + } + if (strcmp(info.routes[i].path, "https://api.example.com/v1/orders") == 0) { + saw_code_url = true; + ASSERT_STR_EQ(info.routes[i].method, "GET"); + } + ASSERT_STR_NEQ(info.routes[i].path, "https://example.com/api/orders"); + } + ASSERT_TRUE(saw_app_route); + ASSERT_TRUE(saw_code_url); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} - ASSERT_EQ(info.route_count, 1); - ASSERT_STR_EQ(info.routes[0].method, "POST"); - ASSERT_STR_EQ(info.routes[0].path, "/api/orders"); - ASSERT_STR_EQ(info.routes[0].handler, "HandleRequest"); +TEST(arch_routes_selects_result_limit_after_filtering) { + enum { REJECTED_ROUTE_PREFIX_COUNT = 256 }; + cbm_store_t *s = setup_arch_test_store(); + + for (int i = 0; i < REJECTED_ROUTE_PREFIX_COUNT; i++) { + char name[TEST_ARCH_PATH_BUF]; + char qn[TEST_ARCH_PATH_BUF]; + snprintf(name, sizeof(name), "https://infra-%03d.example.invalid/service", i); + snprintf(qn, sizeof(qn), "__route__infra__%s", name); + cbm_node_t infra_route = {.project = "test", + .label = "Route", + .name = name, + .qualified_name = qn, + .properties_json = "{\"source\":\"infra\"}"}; + ASSERT_GT(cbm_store_upsert_node(s, &infra_route), 0); + } + + cbm_node_t late_route = {.project = "test", + .label = "Route", + .name = "/late-route", + .qualified_name = "__route__GET__/late-route", + .properties_json = "{\"method\":\"GET\",\"path\":\"/late-route\"}"}; + ASSERT_GT(cbm_store_upsert_node(s, &late_route), 0); + + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"routes"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + bool saw_late_route = false; + for (int i = 0; i < info.route_count; i++) { + if (strcmp(info.routes[i].path, "/late-route") == 0) { + saw_late_route = true; + break; + } + } cbm_store_architecture_free(&info); cbm_store_close(s); + ASSERT_TRUE(saw_late_route); PASS(); } @@ -359,7 +557,7 @@ TEST(arch_hotspots) { cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"hotspots"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); ASSERT_TRUE(info.hotspot_count > 0); /* ProcessOrder should be a hotspot (called by HandleRequest) */ @@ -383,7 +581,7 @@ TEST(arch_boundaries) { cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"boundaries"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); ASSERT_TRUE(info.boundary_count > 0); /* server → handler and handler → service should be present */ @@ -449,7 +647,7 @@ static double timed_boundaries_ms(int n_nodes, int n_edges, int n_pkgs) { const char *aspects[] = {"boundaries"}; struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); - int rc = cbm_store_get_architecture(s, "perf", NULL, aspects, 1, &info); + int rc = cbm_store_get_architecture(s, "perf", aspects, 1, &info, 0, 1.0); clock_gettime(CLOCK_MONOTONIC, &t1); double ms = (double)(t1.tv_sec - t0.tv_sec) * 1000.0 + (double)(t1.tv_nsec - t0.tv_nsec) / 1000000.0; @@ -497,7 +695,7 @@ TEST(arch_layers) { cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"layers"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); ASSERT_TRUE(info.layer_count > 0); /* Handler package has routes, should be "api" */ @@ -512,12 +710,194 @@ TEST(arch_layers) { PASS(); } +TEST(arch_layers_filter_infra_routes_and_use_route_file_package) { + cbm_store_t *s = setup_arch_test_store(); + cbm_node_t modern_route = { + .project = "test", + .label = "Route", + .name = "/api/status", + .qualified_name = "__route__ANY__/api/status", + .file_path = "graph-ui/src/components/StatsTab.tsx", + .properties_json = "{\"method\":\"ANY\",\"path\":\"/api/status\"}"}; + cbm_store_upsert_node(s, &modern_route); + cbm_node_t infra_url = { + .project = "test", + .label = "Route", + .name = "https://github.com/DeusData/codebase-memory-mcp/issues", + .qualified_name = "__route__infra__https://github.com/DeusData/codebase-memory-mcp/issues", + .file_path = "pkg/winget/manifest.yaml", + .properties_json = "{\"source\":\"infra\",\"key_path\":\"PackageUrl\"}"}; + cbm_store_upsert_node(s, &infra_url); + + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"layers"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + bool saw_components_api = false; + for (int i = 0; i < info.layer_count; i++) { + ASSERT_STR_NEQ(info.layers[i].name, ""); + ASSERT_STR_NEQ(info.layers[i].name, "com/DeusData"); + ASSERT_STR_NEQ(info.layers[i].name, "com/DeusData/codebase-memory-mcp/issues"); + if (strcmp(info.layers[i].name, "components") == 0) { + saw_components_api = true; + ASSERT_STR_EQ(info.layers[i].layer, "api"); + ASSERT_STR_EQ(info.layers[i].reason, "has HTTP route definitions"); + } + } + ASSERT_TRUE(saw_components_api); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + +TEST(arch_layers_collects_route_and_entry_packages_beyond_32) { + enum { LAYER_MARKED_PACKAGE_COUNT = 40 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "layer-marked", "/tmp/layer-marked"), CBM_STORE_OK); + + for (int i = 0; i < LAYER_MARKED_PACKAGE_COUNT; i++) { + char route_name[TEST_ARCH_PATH_BUF]; + char route_qn[TEST_ARCH_PATH_BUF]; + char route_file[TEST_ARCH_PATH_BUF]; + snprintf(route_name, sizeof(route_name), "/route-%03d", i); + snprintf(route_qn, sizeof(route_qn), "__route__GET__%s", route_name); + snprintf(route_file, sizeof(route_file), "pkg%03d/routes.c", i); + cbm_node_t route = {.project = "layer-marked", + .label = "Route", + .name = route_name, + .qualified_name = route_qn, + .file_path = route_file, + .properties_json = "{\"method\":\"GET\"}"}; + ASSERT_GT(cbm_store_upsert_node(s, &route), 0); + + char entry_name[TEST_ARCH_PATH_BUF]; + char entry_qn[TEST_ARCH_PATH_BUF]; + snprintf(entry_name, sizeof(entry_name), "entry%03d", i); + snprintf(entry_qn, sizeof(entry_qn), "layer-marked.pkg%03d.%s", i, entry_name); + cbm_node_t entry = {.project = "layer-marked", + .label = "Function", + .name = entry_name, + .qualified_name = entry_qn, + .file_path = route_file, + .properties_json = "{\"is_entry_point\":true}"}; + ASSERT_GT(cbm_store_upsert_node(s, &entry), 0); + } + + cbm_architecture_info_t info = {0}; + const char *aspects[] = {"layers"}; + ASSERT_EQ(cbm_store_get_architecture(s, "layer-marked", aspects, 1, &info, 0, 1.0), + CBM_STORE_OK); + + bool saw_late_api_package = false; + for (int i = 0; i < info.layer_count; i++) { + if (strcmp(info.layers[i].name, "pkg039") == 0) { + saw_late_api_package = strcmp(info.layers[i].layer, "api") == 0; + break; + } + } + + cbm_store_architecture_free(&info); + cbm_store_close(s); + ASSERT_TRUE(saw_late_api_package); + PASS(); +} + +TEST(arch_layers_collects_boundary_packages_beyond_64) { + enum { LAYER_BOUNDARY_TARGET_COUNT = 70 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "layer-boundaries", "/tmp/layer-boundaries"), + CBM_STORE_OK); + + cbm_node_t hub = {.project = "layer-boundaries", + .label = "Function", + .name = "hub", + .qualified_name = "layer-boundaries.hub.call", + .file_path = "hub/call.c"}; + int64_t hub_id = cbm_store_upsert_node(s, &hub); + ASSERT_GT(hub_id, 0); + + int64_t last_target_id = 0; + for (int i = 0; i < LAYER_BOUNDARY_TARGET_COUNT; i++) { + char name[TEST_ARCH_PATH_BUF]; + char qn[TEST_ARCH_PATH_BUF]; + char file_path[TEST_ARCH_PATH_BUF]; + snprintf(name, sizeof(name), "target%03d", i); + snprintf(qn, sizeof(qn), "layer-boundaries.pkg%03d.%s", i, name); + snprintf(file_path, sizeof(file_path), "pkg%03d/target.c", i); + cbm_node_t target = {.project = "layer-boundaries", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = file_path}; + int64_t target_id = cbm_store_upsert_node(s, &target); + ASSERT_GT(target_id, 0); + last_target_id = target_id; + cbm_edge_t edge = {.project = "layer-boundaries", + .source_id = hub_id, + .target_id = target_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &edge), 0); + } + + for (int i = 0; i < 4; i++) { + char name[TEST_ARCH_PATH_BUF]; + char qn[TEST_ARCH_PATH_BUF]; + snprintf(name, sizeof(name), "extra_hub%03d", i); + snprintf(qn, sizeof(qn), "layer-boundaries.hub.%s", name); + cbm_node_t extra_hub = {.project = "layer-boundaries", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "hub/call.c"}; + int64_t extra_hub_id = cbm_store_upsert_node(s, &extra_hub); + ASSERT_GT(extra_hub_id, 0); + cbm_edge_t edge = {.project = "layer-boundaries", + .source_id = extra_hub_id, + .target_id = last_target_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &edge), 0); + } + + cbm_architecture_info_t info = {0}; + const char *aspects[] = {"boundaries", "layers"}; + ASSERT_EQ(cbm_store_get_architecture(s, "layer-boundaries", aspects, 2, &info, 0, 1.0), + CBM_STORE_OK); + + int layer_count = info.layer_count; + bool saw_last_package = false; + bool saw_high_count_boundary = false; + for (int i = 0; i < info.layer_count; i++) { + if (strcmp(info.layers[i].name, "pkg069") == 0) { + saw_last_package = true; + break; + } + } + for (int i = 0; i < info.boundary_count; i++) { + if (strcmp(info.boundaries[i].from, "hub") == 0 && + strcmp(info.boundaries[i].to, "pkg069") == 0 && info.boundaries[i].call_count == 5) { + saw_high_count_boundary = true; + break; + } + } + + cbm_store_architecture_free(&info); + cbm_store_close(s); + ASSERT_EQ(layer_count, LAYER_BOUNDARY_TARGET_COUNT + 1); + ASSERT_TRUE(saw_last_package); + ASSERT_TRUE(saw_high_count_boundary); + PASS(); +} + TEST(arch_file_tree) { cbm_store_t *s = setup_arch_test_store(); cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"file_tree"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); ASSERT_TRUE(info.file_tree_count > 0); /* Check that entries have valid types */ @@ -531,12 +911,102 @@ TEST(arch_file_tree) { PASS(); } +TEST(arch_file_tree_keeps_directories_beyond_former_working_set) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "file-tree-scale", "/tmp/file-tree-scale"), CBM_STORE_OK); + + for (int i = 0; i < TEST_ARCH_FILE_TREE_DISTINCT_DIRS; i++) { + char name[TEST_ARCH_FALLBACK_NAME_BUF]; + char qn[TEST_ARCH_PATH_BUF]; + char file_path[TEST_ARCH_PATH_BUF]; + ASSERT_TRUE(snprintf(name, sizeof(name), "file%03d.c", i) > 0); + ASSERT_TRUE(snprintf(qn, sizeof(qn), "file-tree-scale.root%03d.file", i) > 0); + ASSERT_TRUE(snprintf(file_path, sizeof(file_path), "root%03d/%s", i, name) > 0); + cbm_node_t file = {.project = "file-tree-scale", + .label = "File", + .name = name, + .qualified_name = qn, + .file_path = file_path}; + ASSERT_GT(cbm_store_upsert_node(s, &file), 0); + } + + cbm_architecture_info_t info = {0}; + const char *aspects[] = {"file_tree"}; + ASSERT_EQ(cbm_store_get_architecture(s, "file-tree-scale", aspects, 1, &info, 0, 1.0), + CBM_STORE_OK); + + bool saw_last_dir = false; + bool saw_last_file = false; + for (int i = 0; i < info.file_tree_count; i++) { + if (strcmp(info.file_tree[i].path, "root069") == 0 && + strcmp(info.file_tree[i].type, "dir") == 0 && info.file_tree[i].children == 1) { + saw_last_dir = true; + } + if (strcmp(info.file_tree[i].path, "root069/file069.c") == 0 && + strcmp(info.file_tree[i].type, "file") == 0) { + saw_last_file = true; + } + } + + int file_tree_count = info.file_tree_count; + cbm_store_architecture_free(&info); + cbm_store_close(s); + ASSERT_EQ(file_tree_count, TEST_ARCH_FILE_TREE_DISTINCT_DIRS * 2); + ASSERT_TRUE(saw_last_dir); + ASSERT_TRUE(saw_last_file); + PASS(); +} + +TEST(arch_file_tree_keeps_paths_longer_than_split_scratch_buffer) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "file-tree-long-path", "/tmp/file-tree-long-path"), + CBM_STORE_OK); + + char component[TEST_ARCH_FILE_TREE_LONG_COMPONENT + 1]; + memset(component, 'a', TEST_ARCH_FILE_TREE_LONG_COMPONENT); + component[TEST_ARCH_FILE_TREE_LONG_COMPONENT] = '\0'; + char file_path[TEST_ARCH_LONG_PATH_BUF]; + ASSERT_TRUE(snprintf(file_path, sizeof(file_path), "%s/file.c", component) > 0); + cbm_node_t file = {.project = "file-tree-long-path", + .label = "File", + .name = "file.c", + .qualified_name = "file-tree-long-path.long.file", + .file_path = file_path}; + ASSERT_GT(cbm_store_upsert_node(s, &file), 0); + + cbm_architecture_info_t info = {0}; + const char *aspects[] = {"file_tree"}; + ASSERT_EQ(cbm_store_get_architecture(s, "file-tree-long-path", aspects, 1, &info, 0, 1.0), + CBM_STORE_OK); + + bool saw_exact_dir = false; + bool saw_exact_file = false; + for (int i = 0; i < info.file_tree_count; i++) { + if (strcmp(info.file_tree[i].path, component) == 0 && + strcmp(info.file_tree[i].type, "dir") == 0 && info.file_tree[i].children == 1) { + saw_exact_dir = true; + } + if (strcmp(info.file_tree[i].path, file_path) == 0 && + strcmp(info.file_tree[i].type, "file") == 0) { + saw_exact_file = true; + } + } + + cbm_store_architecture_free(&info); + cbm_store_close(s); + ASSERT_TRUE(saw_exact_dir); + ASSERT_TRUE(saw_exact_file); + PASS(); +} + TEST(arch_clusters) { cbm_store_t *s = setup_arch_test_store(); cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"clusters"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); /* With 5 functions and 4 edges, Louvain should find at least 1 cluster */ if (info.cluster_count == 0) { @@ -557,6 +1027,190 @@ TEST(arch_clusters) { PASS(); } +TEST(arch_clusters_reports_budget_exhaustion_without_prefix_results) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "cluster-budget", "/tmp/cluster-budget"), CBM_STORE_OK); + + for (int i = 0; i < TEST_ARCH_CLUSTER_BUDGET_NODES; i++) { + char name[TEST_ARCH_FALLBACK_NAME_BUF]; + char qn[TEST_ARCH_PATH_BUF]; + ASSERT_TRUE(snprintf(name, sizeof(name), "function%04d", i) > 0); + ASSERT_TRUE(snprintf(qn, sizeof(qn), "cluster-budget.pkg.%s", name) > 0); + cbm_node_t node = {.project = "cluster-budget", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "cluster.c"}; + ASSERT_GT(cbm_store_upsert_node(s, &node), 0); + } + + cbm_architecture_info_t info = {0}; + const char *aspects[] = {"clusters"}; + ASSERT_EQ(cbm_store_get_architecture(s, "cluster-budget", aspects, 1, &info, 0, 1.0), + CBM_STORE_OK); + ASSERT_TRUE(info.clusters_omitted_for_budget); + ASSERT_EQ(info.cluster_nodes_total, TEST_ARCH_CLUSTER_BUDGET_NODES); + ASSERT_EQ(info.cluster_node_budget, CBM_DEFAULT_ARCH_CLUSTER_NODE_BUDGET); + ASSERT_EQ(info.cluster_count, 0); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + +TEST(arch_clusters_selects_dominant_context_and_package_after_first_five) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "cluster-ranking", "/tmp/cluster-ranking"), CBM_STORE_OK); + + int total_nodes = TEST_ARCH_CLUSTER_DISTRACTOR_CONTEXTS + TEST_ARCH_CLUSTER_WINNER_MEMBERS; + int64_t *ids = calloc((size_t)total_nodes, sizeof(*ids)); + ASSERT_NOT_NULL(ids); + for (int i = 0; i < total_nodes; i++) { + char name[TEST_ARCH_FALLBACK_NAME_BUF]; + char qn[TEST_ARCH_PATH_BUF]; + bool winner = i >= TEST_ARCH_CLUSTER_DISTRACTOR_CONTEXTS; + if (i == 0) { + ASSERT_TRUE(snprintf(name, sizeof(name), "get") > 0); + } else { + ASSERT_TRUE(snprintf(name, sizeof(name), "member%02d", i) > 0); + } + if (winner) { + ASSERT_TRUE(snprintf(qn, sizeof(qn), "cluster-ranking.winner.context.%s", name) > 0); + } else { + ASSERT_TRUE( + snprintf(qn, sizeof(qn), "cluster-ranking.distractor%d.context.%s", i, name) > 0); + } + cbm_node_t node = {.project = "cluster-ranking", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "cluster.c"}; + ids[i] = cbm_store_upsert_node(s, &node); + ASSERT_GT(ids[i], 0); + } + for (int i = 1; i < total_nodes; i++) { + cbm_edge_t outward = {.project = "cluster-ranking", + .source_id = ids[0], + .target_id = ids[i], + .type = "CALLS"}; + cbm_edge_t inward = {.project = "cluster-ranking", + .source_id = ids[i], + .target_id = ids[0], + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &outward), 0); + ASSERT_GT(cbm_store_insert_edge(s, &inward), 0); + } + free(ids); + + cbm_architecture_info_t info = {0}; + const char *aspects[] = {"clusters"}; + ASSERT_EQ(cbm_store_get_architecture(s, "cluster-ranking", aspects, 1, &info, 0, 1.0), + CBM_STORE_OK); + + bool saw_winner_package = false; + bool saw_winner_context = false; + for (int i = 0; i < info.cluster_count; i++) { + const cbm_cluster_info_t *cluster = &info.clusters[i]; + for (int j = 0; j < cluster->package_count; j++) { + if (strcmp(cluster->packages[j], "winner") == 0) { + saw_winner_package = true; + } + } + if (cluster->label && strstr(cluster->label, "@winner.context")) { + saw_winner_context = true; + } + } + ASSERT_TRUE(saw_winner_package); + ASSERT_TRUE(saw_winner_context); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + +/* #41: Leiden resolution (gamma) is now a config-tunable param on + * cbm_store_get_architecture (default 1.0). Verify the knob is threaded: + * non-default resolutions are accepted, succeed, and yield valid cluster + * output. (The small 5-node setup is too coarse to assert a cluster-count + * difference reliably; this is a contract test that the param flows through to + * cbm_leiden without error and that NaN/non-positive clamps to the default.) */ +TEST(arch_clusters_resolution_knob) { + cbm_store_t *s = setup_arch_test_store(); + const char *aspects[] = {"clusters"}; + double resolutions[] = {0.5, 1.0, 2.0, 10.0}; + for (size_t i = 0; i < sizeof(resolutions) / sizeof(resolutions[0]); i++) { + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, + resolutions[i]), + CBM_STORE_OK); + ASSERT_TRUE(info.cluster_count >= 0); + cbm_store_architecture_free(&info); + } + /* NaN must clamp to the default (1.0), not corrupt — same hardening as the + * PageRank NaN fix (#44). */ + cbm_architecture_info_t nan_info; + memset(&nan_info, 0, sizeof(nan_info)); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &nan_info, 0, + (double)(0.0 / 0.0)), + CBM_STORE_OK); + cbm_store_architecture_free(&nan_info); + cbm_store_close(s); + PASS(); +} + +/* #36: graph analytics (PageRank + architecture) are LANGUAGE-AGNOSTIC — they + * operate on the graph, not source. Contract: a graph built from multiple + * "languages" (file extensions) yields non-trivial PageRank ranks AND a + * successful architecture computation, confirming the analytics chain works + * across the polyglot case (not just single-language). Complements the + * language-agnostic pagerank/arch unit tests + the per-language LSP tests. */ +TEST(analytics_work_across_languages) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_EQ(cbm_store_upsert_project(s, "poly", "/tmp/poly"), CBM_STORE_OK); + /* Nodes from 3 different "languages" (file extensions). */ + const char *exts[] = {"py", "ts", "go"}; + int64_t ids[6]; + int k = 0; + for (int e = 0; e < 3; e++) { + for (int i = 0; i < 2; i++) { + char name[64], qn[96], fp[96]; + snprintf(name, sizeof(name), "fn_%s_%d", exts[e], i); + snprintf(qn, sizeof(qn), "poly.%s", name); + snprintf(fp, sizeof(fp), "src/mod.%s", exts[e]); + cbm_node_t n = {.project = "poly", .label = "Function", .name = name, + .qualified_name = qn, .file_path = fp}; + ids[k] = cbm_store_upsert_node(s, &n); + ASSERT_GT(ids[k], 0); + k++; + } + } + /* Cross-language call edges so PageRank has structure. */ + for (int i = 0; i < 5; i++) { + cbm_edge_t ed = {.project = "poly", .source_id = ids[i], .target_id = ids[i + 1], + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &ed), 0); + } + + /* PageRank must rank nodes (>0 rows) regardless of source language. */ + ASSERT_EQ(cbm_pagerank_compute_default(s, "poly"), 6); + + /* Architecture must succeed on the polyglot node set (language metadata is + * populated at real-index time, so we don't assert language_count here — + * the contract is that the analytics CHAIN runs across a multi-"language" + * graph, not the language detector). */ + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"languages", "entry_points"}; + ASSERT_EQ(cbm_store_get_architecture(s, "poly", aspects, 2, &info, 0, 1.0), + CBM_STORE_OK); + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + /* ── ADR tests ──────────────────────────────────────────────────── */ TEST(adr_store_and_retrieve) { @@ -972,6 +1626,44 @@ TEST(louvain_single_node) { PASS(); } +TEST(louvain_normalizes_duplicate_unsorted_edges) { + int64_t nodes[] = {30, 10, 40, 20}; + cbm_louvain_edge_t edges[] = { + {10, 20}, {20, 10}, {10, 20}, {20, 10}, {10, 20}, + {30, 40}, {40, 30}, {30, 40}, {40, 30}, {30, 40}, + {20, 30}, {10, 10}, {10, 999}, + }; + cbm_louvain_result_t *result = NULL; + int count = 0; + ASSERT_EQ(cbm_louvain(nodes, 4, edges, (int)(sizeof(edges) / sizeof(edges[0])), &result, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 4); + + int c10 = TEST_ARCH_NO_COMMUNITY; + int c20 = TEST_ARCH_NO_COMMUNITY; + int c30 = TEST_ARCH_NO_COMMUNITY; + int c40 = TEST_ARCH_NO_COMMUNITY; + for (int i = 0; i < count; i++) { + if (result[i].node_id == 10) { + c10 = result[i].community; + } else if (result[i].node_id == 20) { + c20 = result[i].community; + } else if (result[i].node_id == 30) { + c30 = result[i].community; + } else if (result[i].node_id == 40) { + c40 = result[i].community; + } + } + ASSERT_EQ(c10, c20); + ASSERT_EQ(c30, c40); + ASSERT_TRUE(c10 != TEST_ARCH_NO_COMMUNITY); + ASSERT_TRUE(c30 != TEST_ARCH_NO_COMMUNITY); + + free(result); + PASS(); +} + TEST(louvain_converges) { /* Two fully connected clusters of 10 nodes each, bridged by one edge */ int64_t nodes[20]; @@ -1240,7 +1932,7 @@ TEST(arch_clusters_basic) { cbm_architecture_info_t info; memset(&info, 0, sizeof(info)); const char *aspects[] = {"clusters"}; - ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); ASSERT_TRUE(info.cluster_count >= 2); /* two dense communities */ for (int i = 0; i < info.cluster_count; i++) { ASSERT_TRUE(info.clusters[i].members >= 2); @@ -1254,6 +1946,189 @@ TEST(arch_clusters_basic) { PASS(); } +TEST(arch_cluster_generic_labels_include_package_context) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + const char *names[] = {"get", "load", "save", "list"}; + int64_t id[8]; + for (int g = 0; g < 2; g++) { + for (int i = 0; i < 4; i++) { + char qn[96]; + snprintf(qn, sizeof(qn), "test.pkg%d.mod.%s%d", g, names[i], g); + cbm_node_t node = {.project = "test", + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "f.go"}; + id[(g * 4) + i] = cbm_store_upsert_node(s, &node); + } + } + + /* Two get-centered stars. `get` is intentionally generic and appears in + * both clusters, so package context is needed to keep labels distinct. */ + for (int g = 0; g < 2; g++) { + int base = g * 4; + for (int i = 1; i < 4; i++) { + cbm_edge_t e1 = {.project = "test", + .source_id = id[base], + .target_id = id[base + i], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e1); + cbm_edge_t e2 = {.project = "test", + .source_id = id[base + i], + .target_id = id[base], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e2); + } + } + + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"clusters"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + bool pkg0 = false; + bool pkg1 = false; + for (int i = 0; i < info.cluster_count; i++) { + if (info.clusters[i].label && strstr(info.clusters[i].label, "get/") == info.clusters[i].label) { + if (strstr(info.clusters[i].label, "@pkg0")) { + pkg0 = true; + } + if (strstr(info.clusters[i].label, "@pkg1")) { + pkg1 = true; + } + } + } + ASSERT_TRUE(pkg0); + ASSERT_TRUE(pkg1); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + +TEST(arch_cluster_generic_labels_include_namespace_context) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + const char *names[] = {"get", "load", "save", "list"}; + const char *contexts[] = {"orders", "billing"}; + int64_t id[8]; + for (int g = 0; g < 2; g++) { + for (int i = 0; i < 4; i++) { + char qn[128]; + int n = snprintf(qn, sizeof(qn), "test.app.%s.%s%d", contexts[g], names[i], g); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(qn)); + cbm_node_t node = {.project = "test", + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "f.go"}; + id[(g * 4) + i] = cbm_store_upsert_node(s, &node); + } + } + + for (int g = 0; g < 2; g++) { + int base = g * 4; + for (int i = 1; i < 4; i++) { + cbm_edge_t e1 = {.project = "test", + .source_id = id[base], + .target_id = id[base + i], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e1); + cbm_edge_t e2 = {.project = "test", + .source_id = id[base + i], + .target_id = id[base], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e2); + } + } + + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"clusters"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + bool orders = false; + bool billing = false; + for (int i = 0; i < info.cluster_count; i++) { + if (info.clusters[i].label && + strstr(info.clusters[i].label, "get/") == info.clusters[i].label) { + if (strstr(info.clusters[i].label, "@app.orders")) { + orders = true; + } + if (strstr(info.clusters[i].label, "@app.billing")) { + billing = true; + } + } + } + ASSERT_TRUE(orders); + ASSERT_TRUE(billing); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + +TEST(arch_cluster_duplicate_nongeneric_labels_are_disambiguated) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + int64_t id[8]; + for (int g = 0; g < 2; g++) { + for (int i = 0; i < 4; i++) { + char qn[128]; + int n = snprintf(qn, sizeof(qn), "test.pkg%d.installer.download%d", g, i); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(qn)); + cbm_node_t node = {.project = "test", + .label = "Function", + .name = "download", + .qualified_name = qn, + .file_path = "install.go"}; + id[(g * 4) + i] = cbm_store_upsert_node(s, &node); + } + } + + for (int g = 0; g < 2; g++) { + int base = g * 4; + for (int i = 1; i < 4; i++) { + cbm_edge_t e1 = {.project = "test", + .source_id = id[base], + .target_id = id[base + i], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e1); + cbm_edge_t e2 = {.project = "test", + .source_id = id[base + i], + .target_id = id[base], + .type = "CALLS"}; + cbm_store_insert_edge(s, &e2); + } + } + + cbm_architecture_info_t info; + memset(&info, 0, sizeof(info)); + const char *aspects[] = {"clusters"}; + ASSERT_EQ(cbm_store_get_architecture(s, "test", aspects, 1, &info, 0, 1.0), CBM_STORE_OK); + + const char *download_labels[2] = {NULL, NULL}; + int seen = 0; + for (int i = 0; i < info.cluster_count && seen < 2; i++) { + const char *label = info.clusters[i].label; + if (label && strstr(label, "download") == label) { + download_labels[seen++] = label; + } + } + ASSERT_EQ(seen, 2); + ASSERT_TRUE(strcmp(download_labels[0], download_labels[1]) != 0); + ASSERT_TRUE(strcmp(download_labels[0], "download") != 0); + ASSERT_TRUE(strcmp(download_labels[1], "download") != 0); + + cbm_store_architecture_free(&info); + cbm_store_close(s); + PASS(); +} + /* ── Helper function tests ──────────────────────────────────────── */ TEST(qn_to_package) { @@ -1410,19 +2285,32 @@ TEST(search_case_sensitive_explicit) { SUITE(store_arch) { /* Architecture */ RUN_TEST(arch_get_all); + RUN_TEST(arch_package_fallback_ranks_all_qualified_names_before_preview); RUN_TEST(arch_entry_points_exclude_tests); + RUN_TEST(arch_path_scoping); RUN_TEST(arch_hotspots_exclude_tests); RUN_TEST(arch_specific_aspects); RUN_TEST(arch_path_scoping); RUN_TEST(arch_empty_project); RUN_TEST(arch_languages); + RUN_TEST(arch_file_summaries_use_overlay_active_tombstones); RUN_TEST(arch_routes); + RUN_TEST(arch_routes_selects_result_limit_after_filtering); RUN_TEST(arch_hotspots); RUN_TEST(arch_boundaries); RUN_TEST(arch_boundaries_no_quadratic_scan); RUN_TEST(arch_layers); + RUN_TEST(arch_layers_filter_infra_routes_and_use_route_file_package); + RUN_TEST(arch_layers_collects_route_and_entry_packages_beyond_32); + RUN_TEST(arch_layers_collects_boundary_packages_beyond_64); RUN_TEST(arch_file_tree); + RUN_TEST(arch_file_tree_keeps_directories_beyond_former_working_set); + RUN_TEST(arch_file_tree_keeps_paths_longer_than_split_scratch_buffer); RUN_TEST(arch_clusters); + RUN_TEST(arch_clusters_reports_budget_exhaustion_without_prefix_results); + RUN_TEST(arch_clusters_selects_dominant_context_and_package_after_first_five); + RUN_TEST(arch_clusters_resolution_knob); + RUN_TEST(analytics_work_across_languages); /* ADR */ RUN_TEST(adr_store_and_retrieve); @@ -1455,10 +2343,14 @@ SUITE(store_arch) { RUN_TEST(louvain_basic); RUN_TEST(louvain_empty); RUN_TEST(louvain_single_node); + RUN_TEST(louvain_normalizes_duplicate_unsorted_edges); RUN_TEST(louvain_converges); RUN_TEST(leiden_multilevel_collapses_noise); RUN_TEST(leiden_resolution_controls_granularity); RUN_TEST(arch_clusters_basic); + RUN_TEST(arch_cluster_generic_labels_include_package_context); + RUN_TEST(arch_cluster_generic_labels_include_namespace_context); + RUN_TEST(arch_cluster_duplicate_nongeneric_labels_are_disambiguated); /* Helpers */ RUN_TEST(qn_to_package); diff --git a/tests/test_store_bulk.c b/tests/test_store_bulk.c index 826cef87e..0185746f6 100644 --- a/tests/test_store_bulk.c +++ b/tests/test_store_bulk.c @@ -5,7 +5,8 @@ * from WAL journal mode. Switching to MEMORY journal mode during bulk writes * makes the database unrecoverable on a crash because the in-memory rollback * journal is lost. WAL mode is inherently crash-safe: uncommitted WAL entries - * are discarded on the next open. + * are simply discarded on the next open. Performance is preserved via + * synchronous=OFF and a larger cache, which are safe with WAL. * * Tests: * bulk_pragma_wal_invariant — journal_mode stays "wal" after begin_bulk @@ -21,6 +22,7 @@ #include #ifndef _WIN32 #include +#include #include #endif @@ -77,7 +79,7 @@ TEST(bulk_pragma_wal_invariant) { char *after = get_journal_mode(db_path); ASSERT_NOT_NULL(after); - ASSERT_STR_EQ(after, "wal"); /* FAILS with bug, PASSES with fix */ + ASSERT_STR_EQ(after, "wal"); /* FAILS if bulk mode switches away from WAL */ free(after); cbm_store_end_bulk(s); @@ -141,7 +143,18 @@ TEST(bulk_crash_recovery) { } ASSERT_GT(pid, 0); int status; - waitpid(pid, &status, 0); + /* Robust wait: leaks --atExit on macOS temporarily SIGSTOPs forked children + * during heap inspection. WUNTRACED lets us detect the stop and send SIGCONT + * so the child can proceed to _exit(). */ + for (;;) { + pid_t r = waitpid(pid, &status, WUNTRACED); + ASSERT_GT((int)r, 0); + if (WIFSTOPPED(status)) { + kill(pid, SIGCONT); + continue; + } + break; + } /* Confirm child exited normally so the write actually occurred. */ ASSERT(WIFEXITED(status) && WEXITSTATUS(status) == 0); diff --git a/tests/test_store_checkpoint.c b/tests/test_store_checkpoint.c index 4f3bcf11b..8caebcb37 100644 --- a/tests/test_store_checkpoint.c +++ b/tests/test_store_checkpoint.c @@ -10,15 +10,30 @@ * space is reclaimed on the next write cycle, not on every checkpoint. */ #include "../src/foundation/compat.h" +#include "../src/foundation/log.h" #include "test_framework.h" #include "test_helpers.h" #include +#include #include #include #include #include #include +static char g_publish_prepare_log[CBM_SZ_4K]; + +static void capture_publish_prepare_log(const char *line) { + if (!line) { + return; + } + size_t used = strlen(g_publish_prepare_log); + size_t available = sizeof(g_publish_prepare_log) - used; + if (available > 1) { + snprintf(g_publish_prepare_log + used, available, "%s\n", line); + } +} + TEST(checkpoint_does_not_truncate_wal) { enum { N_ROWS = 100, PATH_BUF = 256, PATH_BUF_EXT = 300 }; char db_path[PATH_BUF]; @@ -192,8 +207,64 @@ TEST(remove_db_sidecars_rejects_truncated_suffix_path) { PASS(); } +/* An active WAL snapshot can prevent detaching the WAL journal after committed + * frames have been checkpointed. Publication must fail closed instead of + * unlinking sidecars that the reader still needs, and its diagnostic must + * distinguish journal detachment from checkpoint or rename failures. Sealing + * scans W WAL frames in O(W) time and uses O(1) caller memory; after the reader + * releases its snapshot, the journal transition succeeds. */ +TEST(prepare_for_replace_reports_journal_detach_blocked_by_active_reader) { + char *td = th_mktempdir("cbm_publish_reader"); + ASSERT_NOT_NULL(td); + char db_path[CBM_SZ_512]; + snprintf(db_path, sizeof(db_path), "%s/graph.db", td); + + cbm_store_t *writer = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(writer); + ASSERT_EQ(cbm_store_exec(writer, "INSERT INTO projects(name,indexed_at,root_path) " + "VALUES('p','2026-07-31','/tmp/p');"), + CBM_STORE_OK); + + sqlite3 *reader = NULL; + ASSERT_EQ(sqlite3_open_v2(db_path, &reader, SQLITE_OPEN_READONLY, NULL), SQLITE_OK); + ASSERT_EQ(sqlite3_exec(reader, "BEGIN;", NULL, NULL, NULL), SQLITE_OK); + sqlite3_stmt *snapshot = NULL; + ASSERT_EQ(sqlite3_prepare_v2(reader, "SELECT count(*) FROM projects;", CBM_NOT_FOUND, &snapshot, + NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(snapshot), SQLITE_ROW); + sqlite3_finalize(snapshot); + + ASSERT_EQ(cbm_store_exec(writer, "INSERT INTO projects(name,indexed_at,root_path) " + "VALUES('after','2026-07-31','/tmp/after');"), + CBM_STORE_OK); + cbm_store_close(writer); + + g_publish_prepare_log[0] = '\0'; + CBMLogLevel prior_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_DEBUG); + cbm_log_set_sink(capture_publish_prepare_log); + int blocked_rc = cbm_store_prepare_path_for_replace(db_path); + cbm_log_set_sink(NULL); + cbm_log_set_level(prior_level); + + ASSERT_EQ(blocked_rc, CBM_STORE_ERR); + ASSERT_NOT_NULL(strstr(g_publish_prepare_log, "store.publish_prepare.err")); + ASSERT_NOT_NULL(strstr(g_publish_prepare_log, "journal_delete_step")); + + ASSERT_EQ(sqlite3_exec(reader, "COMMIT;", NULL, NULL, NULL), SQLITE_OK); + ASSERT_EQ(sqlite3_close(reader), SQLITE_OK); + ASSERT_EQ(cbm_store_prepare_path_for_replace(db_path), CBM_STORE_OK); + + (void)cbm_remove_db_sidecars(db_path); + (void)cbm_unlink(db_path); + cbm_rmdir(td); + PASS(); +} + SUITE(store_checkpoint) { RUN_TEST(checkpoint_does_not_truncate_wal); RUN_TEST(dump_install_ignores_stale_wal_sidecar); RUN_TEST(remove_db_sidecars_rejects_truncated_suffix_path); + RUN_TEST(prepare_for_replace_reports_journal_detach_blocked_by_active_reader); } diff --git a/tests/test_store_edges.c b/tests/test_store_edges.c index 440ac8571..0572e9da5 100644 --- a/tests/test_store_edges.c +++ b/tests/test_store_edges.c @@ -406,6 +406,61 @@ TEST(store_edge_batch_insert_50) { PASS(); } +TEST(store_edge_batch_bulk_merges_duplicate_properties) { + int64_t ids[40]; + cbm_store_t *s = setup_store_with_nodes(40, ids); + + cbm_edge_t edges[35]; + edges[0] = (cbm_edge_t){.project = "test", + .source_id = ids[0], + .target_id = ids[1], + .type = "CALLS", + .properties_json = "{\"first\":1}"}; + edges[1] = (cbm_edge_t){.project = "test", + .source_id = ids[0], + .target_id = ids[1], + .type = "CALLS", + .properties_json = "{\"second\":2}"}; + for (int i = 2; i < 35; i++) { + edges[i] = (cbm_edge_t){ + .project = "test", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + } + + ASSERT_EQ(cbm_store_insert_edge_batch(s, edges, 35), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 34); + + cbm_edge_t *out = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(s, ids[0], "CALLS", &out, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT(strstr(out[0].properties_json, "\"first\":1") != NULL); + ASSERT(strstr(out[0].properties_json, "\"second\":2") != NULL); + cbm_store_free_edges(out, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_edge_batch_in_transaction_bulk) { + int64_t ids[40]; + cbm_store_t *s = setup_store_with_nodes(40, ids); + + cbm_edge_t edges[35]; + for (int i = 0; i < 35; i++) { + edges[i] = (cbm_edge_t){ + .project = "test", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + } + + ASSERT_EQ(cbm_store_begin(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_insert_edge_batch_in_transaction(s, edges, 35), CBM_STORE_OK); + ASSERT_EQ(cbm_store_commit(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 35); + + cbm_store_close(s); + PASS(); +} + /* ── find_edges_by_source with non-existent source ─────────────── */ TEST(store_edge_find_source_nonexistent) { @@ -651,6 +706,8 @@ SUITE(store_edges) { /* Edge case tests */ RUN_TEST(store_edge_batch_insert_zero_count); RUN_TEST(store_edge_batch_insert_50); + RUN_TEST(store_edge_batch_bulk_merges_duplicate_properties); + RUN_TEST(store_edge_batch_in_transaction_bulk); RUN_TEST(store_edge_find_source_nonexistent); RUN_TEST(store_edge_find_target_nonexistent); RUN_TEST(store_edge_find_type_nonexistent); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index cd37a47ce..01619e37e 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -5,8 +5,14 @@ * TestNodeDedup, TestProjectCRUD, TestUpsertNodeBatch, etc.) */ #include "test_framework.h" -#include +#include "test_graph_diff.h" +#include "test_helpers.h" +#include "test_sqlite_helpers.h" +#include +#include #include +#include +#include #include #include #include @@ -52,7 +58,592 @@ TEST(sql_label_allowlists_match_cbm_label_is_type_like) { PASS(); } +enum { STORE_TEST_SQLITE_AUTO_LEN = -1 }; + +/* ── Exact vector-search ranking ────────────────────────────────── */ + +enum { + STORE_TEST_VECTOR_DIM = 768, + STORE_TEST_KEYWORD_COUNT_BEYOND_OLD_CAP = 33, + STORE_TEST_OLD_PREFILTER_MULTIPLIER = 5, + STORE_TEST_VECTOR_BIND_ID = 1, + STORE_TEST_VECTOR_BIND_PROJECT = 2, + STORE_TEST_VECTOR_BIND_VALUE = 3, + STORE_TEST_VECTOR_BIND_TOKEN = 3, + STORE_TEST_VECTOR_BIND_TOKEN_VALUE = 4, +}; + +static bool store_test_install_vector_tables(cbm_store_t *s) { + sqlite3 *db = cbm_store_get_db(s); + return db && + sqlite3_exec(db, + "CREATE TABLE node_vectors (" + "node_id INTEGER PRIMARY KEY, project TEXT NOT NULL, vector BLOB NOT NULL);" + "CREATE TABLE token_vectors (" + "id INTEGER PRIMARY KEY, project TEXT NOT NULL, token TEXT NOT NULL," + "vector BLOB NOT NULL, idf INTEGER NOT NULL);", + NULL, NULL, NULL) == SQLITE_OK; +} + +static bool store_test_insert_node_vector(cbm_store_t *s, const char *project, const char *name, + const int8_t vector[STORE_TEST_VECTOR_DIM]) { + cbm_node_t node = {.project = project, + .label = "Function", + .name = name, + .qualified_name = name, + .file_path = "src/vector_fixture.c"}; + int64_t node_id = cbm_store_upsert_node(s, &node); + sqlite3_stmt *stmt = NULL; + sqlite3 *db = cbm_store_get_db(s); + if (node_id <= 0 || + sqlite3_prepare_v2(db, "INSERT INTO node_vectors(node_id,project,vector) VALUES(?1,?2,?3)", + STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { + return false; + } + sqlite3_bind_int64(stmt, STORE_TEST_VECTOR_BIND_ID, node_id); + sqlite3_bind_text(stmt, STORE_TEST_VECTOR_BIND_PROJECT, project, STORE_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC); + sqlite3_bind_blob(stmt, STORE_TEST_VECTOR_BIND_VALUE, vector, STORE_TEST_VECTOR_DIM, + SQLITE_STATIC); + bool ok = sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + return ok; +} + +static bool store_test_insert_raw_token_vector(cbm_store_t *s, int id, const char *project, + const char *token, const void *vector, + int vector_bytes) { + sqlite3_stmt *stmt = NULL; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = "INSERT INTO token_vectors(id,project,token,vector,idf) " + "VALUES(?1,?2,?3,?4,1)"; + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { + return false; + } + sqlite3_bind_int(stmt, STORE_TEST_VECTOR_BIND_ID, id); + sqlite3_bind_text(stmt, STORE_TEST_VECTOR_BIND_PROJECT, project, STORE_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC); + sqlite3_bind_text(stmt, STORE_TEST_VECTOR_BIND_TOKEN, token, STORE_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC); + sqlite3_bind_blob(stmt, STORE_TEST_VECTOR_BIND_TOKEN_VALUE, vector, vector_bytes, + SQLITE_STATIC); + bool ok = sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + return ok; +} + +static bool store_test_insert_token_vector(cbm_store_t *s, int id, const char *project, + const char *token, + const int8_t vector[STORE_TEST_VECTOR_DIM]) { + return store_test_insert_raw_token_vector(s, id, project, token, vector, + STORE_TEST_VECTOR_DIM); +} + +TEST(store_vector_search_ranks_every_candidate_for_all_keywords) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "vector-exact-candidates"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/vector-exact-candidates"), CBM_STORE_OK); + ASSERT_TRUE(store_test_install_vector_tables(s)); + + int8_t first[STORE_TEST_VECTOR_DIM] = {0}; + int8_t second[STORE_TEST_VECTOR_DIM] = {0}; + first[0] = INT8_MAX; + second[1] = INT8_MAX; + ASSERT_TRUE(store_test_insert_token_vector(s, 1, project, "first", first)); + ASSERT_TRUE(store_test_insert_token_vector(s, 2, project, "second", second)); + + /* The old first-keyword prefilter fetched exactly five rows for limit=1. + * Six decoys therefore hid the true all-keyword winner despite its higher + * min-cosine score. Exact top-K selection must inspect every candidate. */ + for (int i = 0; i <= STORE_TEST_OLD_PREFILTER_MULTIPLIER; i++) { + char name[32]; + snprintf(name, sizeof(name), "decoy_%d", i); + ASSERT_TRUE(store_test_insert_node_vector(s, project, name, first)); + } + int8_t balanced[STORE_TEST_VECTOR_DIM] = {0}; + balanced[0] = 90; + balanced[1] = 90; + ASSERT_TRUE(store_test_insert_node_vector(s, project, "balanced_winner", balanced)); + + const char *keywords[] = {"first", "second"}; + cbm_vector_result_t *results = NULL; + int count = 0; + ASSERT_EQ(cbm_store_vector_search(s, project, keywords, 2, 1, &results, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(results[0].name, "balanced_winner"); + cbm_store_free_vector_results(results, count); + cbm_store_close(s); + PASS(); +} + +TEST(store_vector_search_without_vector_tables_is_empty_capability) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "vector-capability-absent"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/vector-capability-absent"), CBM_STORE_OK); + /* Read-only legacy and FAST indexes may predate or intentionally omit + * semantic-vector materialization. That is an unavailable capability, not + * a corrupt partial result. Other prepare/step failures must remain loud. */ + ASSERT_EQ(cbm_store_exec(s, "DROP TABLE IF EXISTS node_vectors;" + "DROP TABLE IF EXISTS token_vectors;"), + CBM_STORE_OK); + + const char *keywords[] = {"publish"}; + cbm_vector_result_t *results = (cbm_vector_result_t *)(uintptr_t)SKIP_ONE; + int count = CBM_SZ_16; + ASSERT_EQ(cbm_store_vector_search(s, project, keywords, SKIP_ONE, CBM_SZ_16, &results, &count), + CBM_STORE_OK); + ASSERT_NULL(results); + ASSERT_EQ(count, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_vector_search_uses_every_nonempty_keyword) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "vector-all-keywords"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/vector-all-keywords"), CBM_STORE_OK); + ASSERT_TRUE(store_test_install_vector_tables(s)); + + int8_t first_axis[STORE_TEST_VECTOR_DIM] = {0}; + int8_t second_axis[STORE_TEST_VECTOR_DIM] = {0}; + first_axis[0] = INT8_MAX; + second_axis[1] = INT8_MAX; + char keyword_storage[STORE_TEST_KEYWORD_COUNT_BEYOND_OLD_CAP][32]; + const char *keywords[STORE_TEST_KEYWORD_COUNT_BEYOND_OLD_CAP]; + for (int i = 0; i < STORE_TEST_KEYWORD_COUNT_BEYOND_OLD_CAP; i++) { + snprintf(keyword_storage[i], sizeof(keyword_storage[i]), "keyword_%d", i); + keywords[i] = keyword_storage[i]; + const int8_t *vector = i + 1 == STORE_TEST_KEYWORD_COUNT_BEYOND_OLD_CAP ? second_axis + : first_axis; + ASSERT_TRUE(store_test_insert_token_vector(s, i + 1, project, keywords[i], vector)); + } + ASSERT_TRUE(store_test_insert_node_vector(s, project, "first_axis_only", first_axis)); + int8_t balanced[STORE_TEST_VECTOR_DIM] = {0}; + balanced[0] = 90; + balanced[1] = 90; + ASSERT_TRUE(store_test_insert_node_vector(s, project, "all_keywords_winner", balanced)); + + cbm_vector_result_t *results = NULL; + int count = 0; + ASSERT_EQ(cbm_store_vector_search(s, project, keywords, + STORE_TEST_KEYWORD_COUNT_BEYOND_OLD_CAP, 1, &results, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(results[0].name, "all_keywords_winner"); + cbm_store_free_vector_results(results, count); + cbm_store_close(s); + PASS(); +} + +TEST(store_vector_search_allocation_failures_are_atomic) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "vector-allocation-failures"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/vector-allocation-failures"), + CBM_STORE_OK); + ASSERT_TRUE(store_test_install_vector_tables(s)); + + int8_t vector[STORE_TEST_VECTOR_DIM] = {0}; + vector[0] = INT8_MAX; + ASSERT_TRUE(store_test_insert_token_vector(s, 1, project, "keyword", vector)); + enum { STORE_TEST_ROWS_PAST_FIRST_GROWTH = 17 }; + for (int i = 0; i < STORE_TEST_ROWS_PAST_FIRST_GROWTH; i++) { + char name[32]; + snprintf(name, sizeof(name), "allocation_row_%d", i); + ASSERT_TRUE(store_test_insert_node_vector(s, project, name, vector)); + } + + struct { + cbm_store_test_vector_alloc_site_t site; + int successful_before; + } cases[] = { + {CBM_STORE_TEST_VECTOR_ALLOC_KEYWORDS, 0}, + {CBM_STORE_TEST_VECTOR_ALLOC_KEYWORDS, 1}, + {CBM_STORE_TEST_VECTOR_ALLOC_RESULT_STRING, 0}, + {CBM_STORE_TEST_VECTOR_ALLOC_RESULT_STRING, STORE_TEST_ROWS_PAST_FIRST_GROWTH * 4 - 4}, + {CBM_STORE_TEST_VECTOR_ALLOC_RESULT_RESERVE, 0}, + {CBM_STORE_TEST_VECTOR_ALLOC_RESULT_RESERVE, 1}, + }; + const char *keywords[] = {"keyword"}; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + cbm_vector_result_t *results = (cbm_vector_result_t *)s; + int count = -1; + cbm_store_test_fail_vector_allocation(cases[i].site, cases[i].successful_before); + ASSERT_EQ(cbm_store_vector_search(s, project, keywords, 1, + STORE_TEST_ROWS_PAST_FIRST_GROWTH, &results, &count), + CBM_STORE_ERR); + ASSERT_NULL(results); + ASSERT_EQ(count, 0); + ASSERT_NOT_NULL(strstr(cbm_store_error(s), "allocation failed")); + } + cbm_store_test_fail_vector_allocation(CBM_STORE_TEST_VECTOR_ALLOC_NONE, -1); + cbm_store_close(s); + PASS(); +} + +TEST(store_vector_search_excludes_zero_magnitude_nodes) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "vector-zero-node"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/vector-zero-node"), CBM_STORE_OK); + ASSERT_TRUE(store_test_install_vector_tables(s)); + + int8_t keyword_vector[STORE_TEST_VECTOR_DIM] = {0}; + int8_t zero_vector[STORE_TEST_VECTOR_DIM] = {0}; + int8_t opposite_vector[STORE_TEST_VECTOR_DIM] = {0}; + keyword_vector[0] = INT8_MAX; + opposite_vector[0] = -INT8_MAX; + ASSERT_TRUE(store_test_insert_token_vector(s, 1, project, "keyword", keyword_vector)); + ASSERT_TRUE(store_test_insert_node_vector(s, project, "undefined_zero_vector", zero_vector)); + ASSERT_TRUE(store_test_insert_node_vector(s, project, "valid_negative_similarity", + opposite_vector)); + + const char *keywords[] = {"keyword"}; + cbm_vector_result_t *results = NULL; + int count = 0; + ASSERT_EQ(cbm_store_vector_search(s, project, keywords, 1, 1, &results, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(results[0].name, "valid_negative_similarity"); + cbm_store_free_vector_results(results, count); + cbm_store_close(s); + PASS(); +} + +TEST(store_vector_search_rejects_malformed_enriched_keyword_vector) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + const char *project = "vector-malformed-keyword"; + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/vector-malformed-keyword"), CBM_STORE_OK); + ASSERT_TRUE(store_test_install_vector_tables(s)); + + const int8_t malformed_vector[] = {INT8_MAX}; + ASSERT_TRUE(store_test_insert_raw_token_vector(s, 1, project, "malformed", malformed_vector, + (int)sizeof(malformed_vector))); + int8_t node_vector[STORE_TEST_VECTOR_DIM] = {0}; + node_vector[0] = INT8_MAX; + ASSERT_TRUE(store_test_insert_node_vector(s, project, "candidate", node_vector)); + + const char *keywords[] = {"malformed"}; + cbm_vector_result_t *results = (cbm_vector_result_t *)s; + int count = -1; + ASSERT_EQ(cbm_store_vector_search(s, project, keywords, 1, 1, &results, &count), + CBM_STORE_ERR); + ASSERT_NULL(results); + ASSERT_EQ(count, 0); + ASSERT_NOT_NULL(strstr(cbm_store_error(s), "token vector")); + cbm_store_close(s); + PASS(); +} + /* ── Schema / Open / Close ──────────────────────────────────────── */ +enum { + STORE_TEST_BIND_PROJECT = 1, + STORE_TEST_BIND_GENERATION = 2, + STORE_TEST_BIND_STATUS = 3, + STORE_TEST_BIND_REPO_FINGERPRINT = 4, + STORE_TEST_BIND_CONFIG_FINGERPRINT = 5, + STORE_TEST_BIND_COMPLETED_STATE = 6, +}; +enum { + STORE_TEST_COMPLETED_NULL = 0, + STORE_TEST_COMPLETED_SET = 1, +}; +typedef enum { + STORE_TEST_OVERLAY_NODES, + STORE_TEST_OVERLAY_EDGES, + STORE_TEST_OVERLAY_TOMBSTONES, +} store_test_overlay_table_t; +enum { + STORE_TEST_OVERLAY_ROW_CONTEXT = 0, + STORE_TEST_OVERLAY_ROW_OWNED = 1, +}; + +static const char STORE_TEST_INVALID_DERIVED_STATUS[] = "fresh-ish"; +static const char *const STORE_TEST_GRAPH_DERIVED_VIEWS[] = { + CBM_STORE_DERIVED_VIEW_PAGERANK, CBM_STORE_DERIVED_VIEW_LINKRANK, + CBM_STORE_DERIVED_VIEW_NODE_DEGREE, CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + CBM_STORE_DERIVED_VIEW_ROUTES, CBM_STORE_DERIVED_VIEW_ARCHITECTURE, +}; + +static int store_publish_helper_file_delta(cbm_store_t *s, int64_t generation); +static int store_publish_old_main_delta(cbm_store_t *s, int64_t generation); + +static int store_count_index_generation(cbm_store_t *s, const char *project, int64_t generation, + const char *status, const char *repo_fingerprint, + const char *config_fingerprint, int completed_state) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = "SELECT COUNT(*) FROM index_generations " + "WHERE project = ?1 AND generation = ?2 AND status = ?3 " + "AND repo_fingerprint = ?4 AND config_fingerprint = ?5 " + "AND ((?6 = 0 AND completed_at IS NULL) OR " + "(?6 = 1 AND completed_at IS NOT NULL)) AND started_at <> ''"; + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, STORE_TEST_BIND_PROJECT, project, STORE_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC); + sqlite3_bind_int64(stmt, STORE_TEST_BIND_GENERATION, generation); + sqlite3_bind_text(stmt, STORE_TEST_BIND_STATUS, status, STORE_TEST_SQLITE_AUTO_LEN, + SQLITE_STATIC); + sqlite3_bind_text(stmt, STORE_TEST_BIND_REPO_FINGERPRINT, repo_fingerprint, + STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_text(stmt, STORE_TEST_BIND_CONFIG_FINGERPRINT, config_fingerprint, + STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int(stmt, STORE_TEST_BIND_COMPLETED_STATE, completed_state); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static int store_count_overlay_generation_row(cbm_store_t *s, const char *project, + int64_t overlay_generation, + int64_t base_generation, const char *status) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = "SELECT COUNT(*) FROM overlay_generations " + "WHERE project = ?1 AND overlay_generation = ?2 " + "AND base_generation = ?3 AND status = ?4 " + "AND created_at <> '' AND updated_at <> ''"; + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, project, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, overlay_generation); + sqlite3_bind_int64(stmt, 3, base_generation); + sqlite3_bind_text(stmt, 4, status, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static const char *store_overlay_count_sql(store_test_overlay_table_t table) { + switch (table) { + case STORE_TEST_OVERLAY_NODES: + return "SELECT COUNT(*) FROM overlay_nodes WHERE project = ?1 " + "AND overlay_generation = ?2 AND rel_path = ?3"; + case STORE_TEST_OVERLAY_EDGES: + return "SELECT COUNT(*) FROM overlay_edges WHERE project = ?1 " + "AND overlay_generation = ?2 AND rel_path = ?3"; + case STORE_TEST_OVERLAY_TOMBSTONES: + return "SELECT COUNT(*) FROM overlay_tombstones WHERE project = ?1 " + "AND overlay_generation = ?2 AND rel_path = ?3"; + } + return NULL; +} + +static const char *store_overlay_owned_count_sql(store_test_overlay_table_t table) { + switch (table) { + case STORE_TEST_OVERLAY_NODES: + return "SELECT COUNT(*) FROM overlay_nodes WHERE project = ?1 " + "AND overlay_generation = ?2 AND rel_path = ?3 AND owned = ?4"; + case STORE_TEST_OVERLAY_EDGES: + return "SELECT COUNT(*) FROM overlay_edges WHERE project = ?1 " + "AND overlay_generation = ?2 AND rel_path = ?3 AND owned = ?4"; + case STORE_TEST_OVERLAY_TOMBSTONES: + return NULL; + } + return NULL; +} + +static int store_count_overlay_rows(cbm_store_t *s, store_test_overlay_table_t table, + const char *project, + int64_t overlay_generation, const char *rel_path) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = store_overlay_count_sql(table); + if (!sql || !db || + sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, project, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, overlay_generation); + sqlite3_bind_text(stmt, 3, rel_path, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static int store_count_overlay_owned_rows(cbm_store_t *s, store_test_overlay_table_t table, + const char *project, + int64_t overlay_generation, const char *rel_path, + int owned) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = store_overlay_owned_count_sql(table); + if (!sql || !db || + sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, project, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, overlay_generation); + sqlite3_bind_text(stmt, 3, rel_path, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int(stmt, 4, owned); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static int store_count_overlay_fts_matches(cbm_store_t *s, const char *project, + int64_t overlay_generation, const char *rel_path, + const char *query) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = + "SELECT COUNT(*) FROM " CBM_STORE_DERIVED_VIEW_NODES_FTS_OVERLAY + " JOIN overlay_nodes n" + " ON n.id = " CBM_STORE_DERIVED_VIEW_NODES_FTS_OVERLAY ".rowid" + " WHERE " CBM_STORE_DERIVED_VIEW_NODES_FTS_OVERLAY " MATCH ?1" + " AND n.project = ?2 AND n.overlay_generation = ?3 AND n.rel_path = ?4"; + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, query, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, project, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 3, overlay_generation); + sqlite3_bind_text(stmt, 4, rel_path, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static int store_count_overlay_fts_raw_matches(cbm_store_t *s, const char *query) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = + "SELECT COUNT(*) FROM " CBM_STORE_DERIVED_VIEW_NODES_FTS_OVERLAY + " WHERE " CBM_STORE_DERIVED_VIEW_NODES_FTS_OVERLAY " MATCH ?1"; + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, query, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static int store_count_metadata_owners(cbm_store_t *s, int edge, const char *project, + const char *rel_path) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + const char *sql = edge ? "SELECT COUNT(*) FROM edge_owners WHERE project = ?1 AND rel_path = ?2" + : "SELECT COUNT(*) FROM node_owners WHERE project = ?1 AND rel_path = ?2"; + sqlite3 *db = cbm_store_get_db(s); + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, project, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, rel_path, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static void store_free_string_array(char **items, int count) { + if (!items) { + return; + } + for (int i = 0; i < count; i++) { + free(items[i]); + } + free(items); +} + +static int store_node_qn_exists(cbm_store_t *s, const char *project, const char *qn) { + cbm_node_t node = {0}; + int rc = cbm_store_find_node_by_qn(s, project, qn, &node); + if (rc == CBM_STORE_OK) { + cbm_node_free_fields(&node); + return 1; + } + return 0; +} + +static int store_count_derived_view_state(cbm_store_t *s, const char *project, + const char *view_name, int64_t generation, + const char *status) { + sqlite3_stmt *stmt = NULL; + int count = CBM_STORE_ERR; + sqlite3 *db = cbm_store_get_db(s); + const char *sql = "SELECT COUNT(*) FROM derived_view_state " + "WHERE project = ?1 AND view_name = ?2 AND source_generation = ?3 " + "AND status = ?4"; + if (!db || sqlite3_prepare_v2(db, sql, STORE_TEST_SQLITE_AUTO_LEN, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + sqlite3_bind_text(stmt, 1, project, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_text(stmt, 2, view_name, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 3, generation); + sqlite3_bind_text(stmt, 4, status, STORE_TEST_SQLITE_AUTO_LEN, SQLITE_STATIC); + if (sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return count; +} + +static int store_count_stale_graph_derived_views(cbm_store_t *s, const char *project, + int64_t generation) { + int total = 0; + for (size_t i = 0; i < sizeof(STORE_TEST_GRAPH_DERIVED_VIEWS) / + sizeof(STORE_TEST_GRAPH_DERIVED_VIEWS[0]); + i++) { + int count = + store_count_derived_view_state(s, project, STORE_TEST_GRAPH_DERIVED_VIEWS[i], + generation, CBM_STORE_DERIVED_STATUS_STALE); + if (count < 0) { + return count; + } + total += count; + } + return total; +} + +static int store_graph_derived_view_count(void) { + return (int)(sizeof(STORE_TEST_GRAPH_DERIVED_VIEWS) / + sizeof(STORE_TEST_GRAPH_DERIVED_VIEWS[0])); +} + +static int store_string_array_contains(char **items, int count, const char *needle) { + for (int i = 0; i < count; i++) { + if (strcmp(items[i], needle) == 0) { + return 1; + } + } + return 0; +} TEST(store_open_memory) { cbm_store_t *s = cbm_store_open_memory(); @@ -77,6 +668,85 @@ TEST(store_open_memory_twice) { PASS(); } +TEST(store_exact_delta_metadata_schema) { + static const char *tables[] = { + "index_generations", "file_state", "node_owners", "edge_owners", + "symbol_exports", "import_refs", "derived_view_state", "overlay_generations", + "overlay_compaction_claims", "overlay_nodes", "overlay_edges", "overlay_tombstones", + "overlay_file_hashes", + "overlay_file_state", "overlay_symbol_exports", "overlay_import_refs", + "overlay_delta_meta", + }; + static const char *indexes[] = { + "idx_file_state_hash", "idx_node_owners_path", "idx_node_owners_node_id", + "idx_edge_owners_path", "idx_edge_owners_edge_id", "idx_symbol_exports_path", + "idx_symbol_exports_node_id", "idx_import_refs_target", + "idx_derived_view_state_status", "idx_overlay_generations_status", + "idx_overlay_compaction_claims_project", + "idx_overlay_nodes_project_gen", "idx_overlay_nodes_project_gen_qn", + "idx_overlay_edges_project_gen", "idx_overlay_tombstones_project_gen", + "idx_overlay_file_state_project_gen", "idx_overlay_import_refs_target", + "idx_overlay_symbol_exports_path", "idx_overlay_delta_meta_project_gen", + }; + + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + sqlite3 *db = cbm_store_get_db(s); + ASSERT_NOT_NULL(db); + + for (size_t i = 0; i < sizeof(tables) / sizeof(tables[0]); i++) { + ASSERT_TRUE(cbm_test_sqlite_object_exists(db, "table", tables[i])); + } + for (size_t i = 0; i < sizeof(indexes) / sizeof(indexes[0]); i++) { + ASSERT_TRUE(cbm_test_sqlite_object_exists(db, "index", indexes[i])); + } + + cbm_store_close(s); + PASS(); +} + +TEST(store_open_path_query_does_not_create_missing_db) { + char path[CBM_SZ_256]; + int n = snprintf(path, sizeof(path), "%s/cbm_store_query_missing_XXXXXX", cbm_tmpdir()); + ASSERT_GT(n, 0); + ASSERT_LT(n, (int)sizeof(path)); + int fd = cbm_mkstemp_s(path, sizeof(path)); + ASSERT_GT(fd, -1); + cbm_close_fd(fd); + ASSERT_EQ(cbm_unlink(path), 0); + + cbm_store_t *s = cbm_store_open_path_query(path); + ASSERT_NULL(s); + + FILE *probe = fopen(path, "rb"); + ASSERT_NULL(probe); + PASS(); +} + +TEST(store_open_path_existing_requires_existing_writable_db) { + char *tmp_dir = th_mktempdir("cbm_store_existing"); + ASSERT_NOT_NULL(tmp_dir); + char path[CBM_PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/store.db", tmp_dir); + ASSERT_GT(n, 0); + ASSERT_LT(n, (int)sizeof(path)); + + ASSERT_NULL(cbm_store_open_path_existing(path)); + ASSERT_FALSE(cbm_file_exists(path)); + + cbm_store_t *created = cbm_store_open_path(path); + ASSERT_NOT_NULL(created); + cbm_store_close(created); + + cbm_store_t *existing = cbm_store_open_path_existing(path); + ASSERT_NOT_NULL(existing); + ASSERT_EQ(cbm_store_upsert_project(existing, "existing", tmp_dir), CBM_STORE_OK); + cbm_store_close(existing); + + th_cleanup(tmp_dir); + PASS(); +} + /* ── Project CRUD ───────────────────────────────────────────────── */ TEST(store_project_crud) { @@ -114,6 +784,27 @@ TEST(store_project_crud) { PASS(); } +TEST(store_project_reads_reset_cached_statements) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "myproject", "/home/user/myproject"), CBM_STORE_OK); + + cbm_project_t p = {0}; + ASSERT_EQ(cbm_store_get_project(s, "myproject", &p), CBM_STORE_OK); + cbm_project_free_fields(&p); + + cbm_project_t *projects = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_projects(s, &projects, &count), CBM_STORE_OK); + cbm_store_free_projects(projects, count); + + ASSERT_EQ(cbm_store_drop_indexes(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_create_indexes(s), CBM_STORE_OK); + + cbm_store_close(s); + PASS(); +} + TEST(store_project_update) { cbm_store_t *s = cbm_store_open_memory(); cbm_store_upsert_project(s, "test", "/old/path"); @@ -140,6 +831,16 @@ TEST(store_project_update) { TEST(store_project_delete) { cbm_store_t *s = cbm_store_open_memory(); cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_dirty_file_state_t dirty = { + .project = "test", + .rel_path = "retry.c", + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING, + }; + ASSERT_EQ(cbm_store_upsert_dirty_file(s, &dirty), CBM_STORE_OK); + int dirty_pending = 0; + ASSERT_EQ(cbm_store_count_dirty_files(s, "test", &dirty_pending, NULL), CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 1); int rc = cbm_store_delete_project(s, "test"); ASSERT_EQ(rc, CBM_STORE_OK); @@ -147,6 +848,9 @@ TEST(store_project_delete) { cbm_project_t p = {0}; rc = cbm_store_get_project(s, "test", &p); ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + dirty_pending = -1; + ASSERT_EQ(cbm_store_count_dirty_files(s, "test", &dirty_pending, NULL), CBM_STORE_OK); + ASSERT_EQ(dirty_pending, 0); cbm_store_close(s); PASS(); @@ -266,6 +970,181 @@ TEST(store_node_find_by_label) { PASS(); } +typedef struct { + int count; + int saw_a; + int saw_c; + int saw_other_project; +} store_visit_nodes_by_label_ctx_t; + +static int store_visit_nodes_by_label_cb(const char *label, const char *name, + const char *qualified_name, const char *file_path, + void *userdata) { + store_visit_nodes_by_label_ctx_t *ctx = (store_visit_nodes_by_label_ctx_t *)userdata; + if (!ctx || !label || !name || !qualified_name || !file_path) { + return CBM_STORE_ERR; + } + ctx->count++; + if (strcmp(label, "Function") != 0) { + return CBM_STORE_ERR; + } + if (strcmp(name, "A") == 0 && strcmp(qualified_name, "test.A") == 0 && + strcmp(file_path, "main.go") == 0) { + ctx->saw_a = 1; + } + if (strcmp(name, "C") == 0 && strcmp(qualified_name, "test.C") == 0 && + strcmp(file_path, "util.go") == 0) { + ctx->saw_c = 1; + } + if (strcmp(qualified_name, "other.A") == 0) { + ctx->saw_other_project = 1; + } + return CBM_STORE_OK; +} + +TEST(store_visit_nodes_by_label_identity_rows) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + cbm_store_upsert_project(s, "other", "/tmp/other"); + + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "A", + .qualified_name = "test.A", + .file_path = "main.go", + .properties_json = "{\"ignored\":true}"}; + cbm_node_t n2 = {.project = "test", + .label = "Class", + .name = "B", + .qualified_name = "test.B", + .file_path = "main.go"}; + cbm_node_t n3 = {.project = "test", + .label = "Function", + .name = "C", + .qualified_name = "test.C", + .file_path = "util.go"}; + cbm_node_t n4 = {.project = "other", + .label = "Function", + .name = "A", + .qualified_name = "other.A", + .file_path = "main.go"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + cbm_store_upsert_node(s, &n3); + cbm_store_upsert_node(s, &n4); + + store_visit_nodes_by_label_ctx_t ctx = {0}; + int rc = cbm_store_visit_nodes_by_label(s, "test", "Function", + store_visit_nodes_by_label_cb, &ctx); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(ctx.count, 2); + ASSERT_EQ(ctx.saw_a, 1); + ASSERT_EQ(ctx.saw_c, 1); + ASSERT_EQ(ctx.saw_other_project, 0); + + cbm_store_close(s); + PASS(); +} + +typedef struct { + int count; + int64_t zeta_id; + int64_t alpha_id; + int64_t beta_id; + int saw_zeta; + int saw_alpha; + int saw_beta; +} store_visit_node_refs_pattern_ctx_t; + +static int store_visit_node_refs_pattern_cb(int64_t id, const char *label, const char *name, + const char *qualified_name, const char *file_path, + double pagerank, void *userdata) { + (void)qualified_name; + (void)file_path; + store_visit_node_refs_pattern_ctx_t *ctx = userdata; + if (!ctx || ctx->count >= 3 || !label || !name || strcmp(label, "Module") != 0) { + return CBM_STORE_ERR; + } + if (strcmp(name, "zeta") == 0 && id == ctx->zeta_id && pagerank == 0.9) { + ctx->saw_zeta = 1; + } else if (strcmp(name, "alpha") == 0 && id == ctx->alpha_id && pagerank == 0.1) { + ctx->saw_alpha = 1; + } else if (strcmp(name, "beta") == 0 && id == ctx->beta_id && pagerank == 0.1) { + ctx->saw_beta = 1; + } else { + return CBM_STORE_ERR; + } + ctx->count++; + return CBM_STORE_OK; +} + +TEST(store_visit_ranked_node_refs_by_project_pattern_and_label) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT(s != NULL); + ASSERT_EQ(cbm_store_upsert_project(s, "app.dep.a", "/tmp/app-dep-a"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(s, "app.dep.b", "/tmp/app-dep-b"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(s, "other.dep.c", "/tmp/other-dep-c"), CBM_STORE_OK); + + cbm_node_t zeta = {.project = "app.dep.a", + .label = "Module", + .name = "zeta", + .qualified_name = "app.dep.a.zeta", + .file_path = "zeta.py"}; + cbm_node_t alpha = {.project = "app.dep.b", + .label = "Module", + .name = "alpha", + .qualified_name = "app.dep.b.alpha", + .file_path = "alpha.py"}; + cbm_node_t beta = {.project = "app.dep.b", + .label = "Module", + .name = "beta", + .qualified_name = "app.dep.b.beta", + .file_path = "beta.py"}; + cbm_node_t wrong_label = {.project = "app.dep.a", + .label = "Function", + .name = "delta", + .qualified_name = "app.dep.a.delta", + .file_path = "delta.py"}; + cbm_node_t wrong_project = {.project = "other.dep.c", + .label = "Module", + .name = "gamma", + .qualified_name = "other.dep.c.gamma", + .file_path = "gamma.py"}; + int64_t zeta_id = cbm_store_upsert_node(s, &zeta); + int64_t alpha_id = cbm_store_upsert_node(s, &alpha); + int64_t beta_id = cbm_store_upsert_node(s, &beta); + ASSERT(zeta_id > 0); + ASSERT(alpha_id > 0); + ASSERT(beta_id > 0); + ASSERT(cbm_store_upsert_node(s, &wrong_label) > 0); + ASSERT(cbm_store_upsert_node(s, &wrong_project) > 0); + + char rank_sql[CBM_SZ_256]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) VALUES " + "('app.dep.a',%lld,0.9,'2026-07-25T00:00:00Z')," + "('app.dep.b',%lld,0.1,'2026-07-25T00:00:00Z')," + "('app.dep.b',%lld,0.1,'2026-07-25T00:00:00Z')", + (long long)zeta_id, (long long)alpha_id, (long long)beta_id); + ASSERT_EQ(cbm_store_exec(s, rank_sql), CBM_STORE_OK); + + store_visit_node_refs_pattern_ctx_t ctx = { + .zeta_id = zeta_id, + .alpha_id = alpha_id, + .beta_id = beta_id, + }; + int rc = cbm_store_visit_ranked_node_refs_by_project_pattern_and_label( + s, "app.dep.%", "Module", store_visit_node_refs_pattern_cb, &ctx); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(ctx.count, 3); + ASSERT_EQ(ctx.saw_zeta, 1); + ASSERT_EQ(ctx.saw_alpha, 1); + ASSERT_EQ(ctx.saw_beta, 1); + + cbm_store_close(s); + PASS(); +} + TEST(store_node_find_by_file) { cbm_store_t *s = cbm_store_open_memory(); cbm_store_upsert_project(s, "test", "/tmp/test"); @@ -395,150 +1274,4218 @@ TEST(store_node_batch_upsert) { }; } - int rc = cbm_store_upsert_node_batch(s, nodes, 150, ids); - ASSERT_EQ(rc, CBM_STORE_OK); + int rc = cbm_store_upsert_node_batch(s, nodes, 150, ids); + ASSERT_EQ(rc, CBM_STORE_OK); + + /* Verify all IDs are non-zero */ + for (int i = 0; i < 150; i++) { + ASSERT_GT(ids[i], 0); + } + + /* Verify count */ + int cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 150); + + /* Re-upsert should not duplicate */ + int64_t ids2[150]; + rc = cbm_store_upsert_node_batch(s, nodes, 150, ids2); + ASSERT_EQ(rc, CBM_STORE_OK); + cnt = cbm_store_count_nodes(s, "test"); + ASSERT_EQ(cnt, 150); + + /* IDs should be the same */ + for (int i = 0; i < 150; i++) { + ASSERT_EQ(ids[i], ids2[i]); + } + + cbm_store_close(s); + PASS(); +} + +TEST(store_node_batch_empty) { + cbm_store_t *s = cbm_store_open_memory(); + int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL); + ASSERT_EQ(rc, CBM_STORE_OK); + cbm_store_close(s); + PASS(); +} + +TEST(store_node_batch_in_transaction_bulk_rollback) { + enum { STORE_NODE_BATCH_TX_COUNT = 40 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t nodes[STORE_NODE_BATCH_TX_COUNT]; + int64_t ids[STORE_NODE_BATCH_TX_COUNT]; + char names[STORE_NODE_BATCH_TX_COUNT][CBM_SZ_32]; + char qns[STORE_NODE_BATCH_TX_COUNT][CBM_SZ_64]; + for (int i = 0; i < STORE_NODE_BATCH_TX_COUNT; i++) { + snprintf(names[i], sizeof(names[i]), "tx_func_%d", i); + snprintf(qns[i], sizeof(qns[i]), "test.tx.func_%d", i); + nodes[i] = (cbm_node_t){ + .project = "test", + .label = "Function", + .name = names[i], + .qualified_name = qns[i], + .file_path = "tx.c", + .start_line = i + 1, + .end_line = i + 1, + .properties_json = "{}", + }; + } + + ASSERT_EQ(cbm_store_begin(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_node_batch_in_transaction(s, nodes, STORE_NODE_BATCH_TX_COUNT, + ids), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), STORE_NODE_BATCH_TX_COUNT); + for (int i = 0; i < STORE_NODE_BATCH_TX_COUNT; i++) { + ASSERT_GT(ids[i], 0); + } + ASSERT_EQ(cbm_store_rollback(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), 0); + + cbm_store_close(s); + PASS(); +} + +/* ── Cascade delete ─────────────────────────────────────────────── */ + +TEST(store_cascade_delete) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Create nodes and an edge */ + cbm_node_t n1 = { + .project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"}; + cbm_node_t n2 = { + .project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"}; + int64_t id1 = cbm_store_upsert_node(s, &n1); + int64_t id2 = cbm_store_upsert_node(s, &n2); + + cbm_edge_t e = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"}; + cbm_store_insert_edge(s, &e); + + /* Delete project — should cascade */ + cbm_store_delete_project(s, "test"); + + int ncnt = cbm_store_count_nodes(s, "test"); + int ecnt = cbm_store_count_edges(s, "test"); + ASSERT_EQ(ncnt, 0); + ASSERT_EQ(ecnt, 0); + + cbm_store_close(s); + PASS(); +} + +/* ── File hashes ────────────────────────────────────────────────── */ + +TEST(store_file_hash_crud) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Upsert */ + int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512); + ASSERT_EQ(rc, CBM_STORE_OK); + + /* Get */ + cbm_file_hash_t *hashes = NULL; + int count = 0; + rc = cbm_store_get_file_hashes(s, "test", &hashes, &count); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(hashes[0].rel_path, "main.go"); + ASSERT_STR_EQ(hashes[0].sha256, "abc123"); + ASSERT_EQ(hashes[0].mtime_ns, 1000000); + ASSERT_EQ(hashes[0].size, 512); + cbm_store_free_file_hashes(hashes, count); + + /* Update */ + rc = cbm_store_upsert_file_hash(s, "test", "main.go", "def456", 2000000, 1024); + ASSERT_EQ(rc, CBM_STORE_OK); + rc = cbm_store_get_file_hashes(s, "test", &hashes, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(hashes[0].sha256, "def456"); + ASSERT_EQ(hashes[0].mtime_ns, 2000000); + cbm_store_free_file_hashes(hashes, count); + + /* Delete single */ + rc = cbm_store_delete_file_hash(s, "test", "main.go"); + ASSERT_EQ(rc, CBM_STORE_OK); + rc = cbm_store_get_file_hashes(s, "test", &hashes, &count); + ASSERT_EQ(count, 0); + cbm_store_free_file_hashes(hashes, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_hash_upsert_rejects_null_required_fields) { + /* Pins the API contract that `cbm_store_upsert_file_hash` returns + * CBM_STORE_ERR (not silent OK) when a NOT NULL column would receive + * SQL NULL. This is the failure mode that + * `pipeline_incremental.c:persist_hashes` checks for and logs as + * `incremental.persist_hash_failed`. If this contract ever changes + * (e.g. the schema relaxes NOT NULL on rel_path or sha256), the + * downstream warning becomes silent and the orphaned-node bug class + * can re-emerge. Track that change here, not just in the consumer. */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + /* Sanity: a fully-valid upsert returns OK. */ + int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512); + ASSERT_EQ(rc, CBM_STORE_OK); + + /* NULL sha256 violates NOT NULL on file_hashes.sha256 → must return ERR. */ + rc = cbm_store_upsert_file_hash(s, "test", "other.go", NULL, 2000000, 1024); + ASSERT_EQ(rc, CBM_STORE_ERR); + + /* NULL rel_path violates NOT NULL on file_hashes.rel_path → must return ERR. */ + rc = cbm_store_upsert_file_hash(s, "test", NULL, "deadbeef", 3000000, 2048); + ASSERT_EQ(rc, CBM_STORE_ERR); + + /* NULL project violates NOT NULL on file_hashes.project → must return ERR. */ + rc = cbm_store_upsert_file_hash(s, NULL, "third.go", "cafebabe", 4000000, 4096); + ASSERT_EQ(rc, CBM_STORE_ERR); + + /* The valid row from earlier must still be present — partial-failure + * policy: a single bad upsert does not corrupt or remove other rows. */ + cbm_file_hash_t *hashes = NULL; + int count = 0; + cbm_store_get_file_hashes(s, "test", &hashes, &count); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(hashes[0].rel_path, "main.go"); + cbm_store_free_file_hashes(hashes, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_state_crud) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_file_state_t state = { + .project = "test", + .rel_path = "main.go", + .content_hash = "content-a", + .git_oid = "git-a", + .mtime_ns = 1000000, + .size = 512, + .language = "go", + .pass_fingerprint = "pass-a", + .generation = 1, + .indexed_at = "2026-03-14T00:00:00Z", + }; + int rc = cbm_store_upsert_file_state(s, &state); + ASSERT_EQ(rc, CBM_STORE_OK); + + cbm_file_state_t got = {0}; + rc = cbm_store_get_file_state(s, "test", "main.go", &got); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "content-a"); + ASSERT_STR_EQ(got.git_oid, "git-a"); + ASSERT_EQ(got.mtime_ns, 1000000); + ASSERT_EQ(got.size, 512); + ASSERT_STR_EQ(got.language, "go"); + ASSERT_STR_EQ(got.pass_fingerprint, "pass-a"); + ASSERT_EQ(got.generation, 1); + ASSERT_STR_EQ(got.indexed_at, "2026-03-14T00:00:00Z"); + cbm_store_file_state_free_fields(&got); + + state.content_hash = "content-b"; + state.git_oid = ""; + state.mtime_ns = 2000000; + state.size = 1024; + state.language = "c"; + state.pass_fingerprint = "pass-b"; + state.generation = 2; + state.indexed_at = "2026-03-15T00:00:00Z"; + rc = cbm_store_upsert_file_state(s, &state); + ASSERT_EQ(rc, CBM_STORE_OK); + + rc = cbm_store_get_file_state(s, "test", "main.go", &got); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "content-b"); + ASSERT_STR_EQ(got.git_oid, ""); + ASSERT_EQ(got.mtime_ns, 2000000); + ASSERT_EQ(got.size, 1024); + ASSERT_STR_EQ(got.language, "c"); + ASSERT_STR_EQ(got.pass_fingerprint, "pass-b"); + ASSERT_EQ(got.generation, 2); + cbm_store_file_state_free_fields(&got); + + rc = cbm_store_delete_file_state(s, "test", "main.go"); + ASSERT_EQ(rc, CBM_STORE_OK); + rc = cbm_store_get_file_state(s, "test", "main.go", &got); + ASSERT_EQ(rc, CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_state_get_resets_cached_statement) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_file_state_t state = { + .project = "test", + .rel_path = "main.go", + .content_hash = "content-a", + .git_oid = "", + .mtime_ns = 1000000, + .size = 512, + .language = "go", + .pass_fingerprint = "pass-a", + .generation = 1, + .indexed_at = "2026-03-14T00:00:00Z", + }; + ASSERT_EQ(cbm_store_upsert_file_state(s, &state), CBM_STORE_OK); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &got), CBM_STORE_OK); + cbm_store_file_state_free_fields(&got); + + ASSERT_EQ(cbm_store_drop_indexes(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_create_indexes(s), CBM_STORE_OK); + + cbm_store_close(s); + PASS(); +} + +TEST(store_index_generation_reservation_monotonic) { + enum { FIRST_GENERATION = 1, SECOND_GENERATION = 2 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", "repo-a", "config-a", &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FIRST_GENERATION); + ASSERT_EQ(store_count_index_generation(s, "test", FIRST_GENERATION, + CBM_STORE_INDEX_STATUS_RESERVED, "repo-a", + "config-a", STORE_TEST_COMPLETED_NULL), + 1); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, SECOND_GENERATION); + ASSERT_EQ(store_count_index_generation(s, "test", SECOND_GENERATION, + CBM_STORE_INDEX_STATUS_RESERVED, "", "", + STORE_TEST_COMPLETED_NULL), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_index_generation_reservation_requires_project) { + enum { NO_RESERVED_GENERATION = 0, FIRST_GENERATION = 1, SENTINEL_GENERATION = 99 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + + int64_t generation = SENTINEL_GENERATION; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "missing", "repo-a", "config-a", + &generation), + CBM_STORE_ERR); + ASSERT_EQ(generation, NO_RESERVED_GENERATION); + ASSERT_EQ(store_count_index_generation(s, "missing", FIRST_GENERATION, + CBM_STORE_INDEX_STATUS_RESERVED, "repo-a", "config-a", + STORE_TEST_COMPLETED_NULL), + 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_index_generation_finish_complete) { + enum { FIRST_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", "repo-a", "config-a", &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FIRST_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(store_count_index_generation(s, "test", FIRST_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "repo-a", + "config-a", STORE_TEST_COMPLETED_SET), + 1); + ASSERT_EQ(store_count_index_generation(s, "test", FIRST_GENERATION, + CBM_STORE_INDEX_STATUS_RESERVED, "repo-a", + "config-a", STORE_TEST_COMPLETED_NULL), + 0); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_NOT_FOUND); + + cbm_store_close(s); + PASS(); +} + +TEST(store_latest_complete_index_generation_ignores_reserved_and_failed) { + enum { FIRST_GENERATION = 1, SECOND_GENERATION = 2, THIRD_GENERATION = 3 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t latest = -1; + ASSERT_EQ(cbm_store_latest_complete_index_generation(s, "test", &latest), CBM_STORE_OK); + ASSERT_EQ(latest, 0); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FIRST_GENERATION); + ASSERT_EQ(cbm_store_latest_complete_index_generation(s, "test", &latest), CBM_STORE_OK); + ASSERT_EQ(latest, 0); + + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_latest_complete_index_generation(s, "test", &latest), CBM_STORE_OK); + ASSERT_EQ(latest, FIRST_GENERATION); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, SECOND_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_FAILED), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_latest_complete_index_generation(s, "test", &latest), CBM_STORE_OK); + ASSERT_EQ(latest, FIRST_GENERATION); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, THIRD_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_latest_complete_index_generation(s, "test", &latest), CBM_STORE_OK); + ASSERT_EQ(latest, THIRD_GENERATION); + + cbm_store_close(s); + PASS(); +} + +TEST(store_index_generation_finish_failed_and_invalid_status) { + enum { FIRST_GENERATION = 1 }; + const char *invalid_status = "invalid-status"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FIRST_GENERATION); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, invalid_status), + CBM_STORE_ERR); + ASSERT_EQ(store_count_index_generation(s, "test", FIRST_GENERATION, + CBM_STORE_INDEX_STATUS_RESERVED, "", "", + STORE_TEST_COMPLETED_NULL), + 1); + + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_FAILED), + CBM_STORE_OK); + ASSERT_EQ(store_count_index_generation(s, "test", FIRST_GENERATION, + CBM_STORE_INDEX_STATUS_FAILED, "", "", + STORE_TEST_COMPLETED_SET), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_generation_reservation_status_and_counts) { + enum { FIRST_OVERLAY = 1, SECOND_OVERLAY = 2, BASE_GENERATION = 9 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(overlay_generation, FIRST_OVERLAY); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", FIRST_OVERLAY, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_RESERVED), + 1); + + int count = -1; + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", NULL, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", + CBM_STORE_OVERLAY_STATUS_RESERVED, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", overlay_generation, + CBM_STORE_OVERLAY_STATUS_READY), + CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", FIRST_OVERLAY, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", + CBM_STORE_OVERLAY_STATUS_RESERVED, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", CBM_STORE_OVERLAY_STATUS_READY, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(overlay_generation, SECOND_OVERLAY); + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", NULL, &count), CBM_STORE_OK); + ASSERT_EQ(count, 2); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_generation_rejects_invalid_inputs) { + enum { SENTINEL_GENERATION = 42 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = SENTINEL_GENERATION; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "missing", 0, &overlay_generation), + CBM_STORE_ERR); + ASSERT_EQ(overlay_generation, 0); + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", -1, &overlay_generation), + CBM_STORE_ERR); + ASSERT_EQ(overlay_generation, 0); + + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", 0, &overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", overlay_generation, + "almost_ready"), + CBM_STORE_ERR); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", overlay_generation + 1, + CBM_STORE_OVERLAY_STATUS_READY), + CBM_STORE_NOT_FOUND); + + int count = SENTINEL_GENERATION; + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", "almost_ready", &count), + CBM_STORE_ERR); + ASSERT_EQ(count, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_claim_ready_overlay_generation_claims_oldest_once) { + enum { BASE_GENERATION = 9 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t first = 0; + int64_t second = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &first), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION + 1, + &second), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", first, + CBM_STORE_OVERLAY_STATUS_READY), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", second, + CBM_STORE_OVERLAY_STATUS_READY), + CBM_STORE_OK); + + int64_t claimed = 0; + int64_t base = 0; + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_OK); + ASSERT_EQ(claimed, first); + ASSERT_EQ(base, BASE_GENERATION); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", first, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_OK); + ASSERT_EQ(claimed, second); + ASSERT_EQ(base, BASE_GENERATION + 1); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", second, BASE_GENERATION + 1, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + + claimed = -1; + base = -1; + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(claimed, 0); + ASSERT_EQ(base, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_claim_ready_overlay_generation_ignores_nonready_and_validates_outputs) { + enum { BASE_GENERATION = 3, SENTINEL = 42 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t reserved = 0; + int64_t failed = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &reserved), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &failed), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", failed, + CBM_STORE_OVERLAY_STATUS_FAILED), + CBM_STORE_OK); + + int64_t claimed = SENTINEL; + int64_t base = SENTINEL; + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(claimed, 0); + ASSERT_EQ(base, 0); + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", NULL, &base), + CBM_STORE_ERR); + ASSERT_EQ(base, 0); + claimed = SENTINEL; + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, NULL), + CBM_STORE_ERR); + ASSERT_EQ(claimed, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_recover_overlay_compaction_claims_releases_abandoned_claims) { + enum { BASE_GENERATION = 5, SENTINEL = 42 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t claimable = 0; + int64_t failed = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &claimable), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &failed), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", claimable, + CBM_STORE_OVERLAY_STATUS_READY), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", failed, + CBM_STORE_OVERLAY_STATUS_FAILED), + CBM_STORE_OK); + + int64_t claimed = 0; + int64_t base = 0; + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_OK); + ASSERT_EQ(claimed, claimable); + ASSERT_EQ(base, BASE_GENERATION); + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_NOT_FOUND); + + int recovered = SENTINEL; + ASSERT_EQ(cbm_store_recover_overlay_compaction_claims(s, "test", &recovered), + CBM_STORE_OK); + ASSERT_EQ(recovered, 1); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", claimable, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", failed, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_FAILED), + 1); + + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed, &base), + CBM_STORE_OK); + ASSERT_EQ(claimed, claimable); + + recovered = SENTINEL; + ASSERT_EQ(cbm_store_recover_overlay_compaction_claims(s, "test", &recovered), + CBM_STORE_OK); + ASSERT_EQ(recovered, 1); + recovered = SENTINEL; + ASSERT_EQ(cbm_store_recover_overlay_compaction_claims(s, "missing", &recovered), + CBM_STORE_OK); + ASSERT_EQ(recovered, 0); + ASSERT_EQ(cbm_store_recover_overlay_compaction_claims(s, "test", NULL), + CBM_STORE_ERR); + + cbm_store_close(s); + PASS(); +} + +TEST(store_compact_next_overlay_generation_returns_not_found_without_ready_overlay) { + enum { SENTINEL = 42 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = SENTINEL; + int64_t index_generation = SENTINEL; + ASSERT_EQ(cbm_store_compact_next_overlay_generation(s, "test", &overlay_generation, + &index_generation), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(overlay_generation, 0); + ASSERT_EQ(index_generation, 0); + + int compacted = SENTINEL; + ASSERT_EQ(cbm_store_compact_ready_overlay_generations( + s, "test", CBM_STORE_COMPACT_ALL_GENERATIONS, &compacted), + CBM_STORE_OK); + ASSERT_EQ(compacted, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_file_delta_publish_rows_and_tombstone) { + enum { BASE_GENERATION = 3 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + + cbm_node_t context_nodes[] = { + {.project = "test", + .label = "Folder", + .name = "src", + .qualified_name = "test.src", + .file_path = "src", + .properties_json = "{}"}, + }; + cbm_node_t nodes[] = { + {.project = "test", + .label = "Function", + .name = "main", + .qualified_name = "test.main", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "helper", + .qualified_name = "test.helper", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_delta_edge_t edges[] = { + {.source_qn = "test.main", + .target_qn = "test.helper", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}, + }; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .context_nodes = context_nodes, + .context_node_count = 1, + .nodes = nodes, + .node_count = 2, + .edges = edges, + .edge_count = 1}; + + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delta, overlay_generation), CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "main.go"), + 3); + ASSERT_EQ(store_count_overlay_owned_rows(s, STORE_TEST_OVERLAY_NODES, "test", + overlay_generation, "main.go", + STORE_TEST_OVERLAY_ROW_OWNED), + 2); + ASSERT_EQ(store_count_overlay_owned_rows(s, STORE_TEST_OVERLAY_NODES, "test", + overlay_generation, "main.go", + STORE_TEST_OVERLAY_ROW_CONTEXT), + 1); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_EDGES, "test", overlay_generation, + "main.go"), + 1); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "main.go"), + 1); + ASSERT_EQ(store_count_overlay_fts_matches(s, "test", overlay_generation, "main.go", + "helper"), + 1); + + int count = -1; + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", CBM_STORE_OVERLAY_STATUS_READY, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), 0); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 0); + + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delta, overlay_generation), CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "main.go"), + 3); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_EDGES, "test", overlay_generation, + "main.go"), + 1); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "main.go"), + 1); + ASSERT_EQ(store_count_overlay_fts_matches(s, "test", overlay_generation, "main.go", + "helper"), + 1); + + cbm_node_t replacement_nodes[] = { + {.project = "test", + .label = "Function", + .name = "replacement", + .qualified_name = "test.replacement", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t replacement_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .context_nodes = context_nodes, + .context_node_count = 1, + .nodes = replacement_nodes, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &replacement_delta, overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "main.go"), + 2); + ASSERT_EQ(store_count_overlay_fts_matches(s, "test", overlay_generation, "main.go", + "helper"), + 0); + ASSERT_EQ(store_count_overlay_fts_matches(s, "test", overlay_generation, "main.go", + "replacement"), + 1); + + cbm_node_t helper_nodes[] = { + {.project = "test", + .label = "Function", + .name = "other", + .qualified_name = "test.other", + .file_path = "helper.go", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t helper_delta = {.project = "test", + .rel_path = "helper.go", + .generation = BASE_GENERATION, + .context_nodes = context_nodes, + .context_node_count = 1, + .nodes = helper_nodes, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &helper_delta, overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "helper.go"), + 2); + ASSERT_EQ(store_count_overlay_owned_rows(s, STORE_TEST_OVERLAY_NODES, "test", + overlay_generation, "helper.go", + STORE_TEST_OVERLAY_ROW_CONTEXT), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_delete_project_clears_overlay_fts) { + enum { BASE_GENERATION = 3 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + + cbm_node_t nodes[] = { + {.project = "test", + .label = "Function", + .name = "needle_symbol", + .qualified_name = "test.needle_symbol", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = nodes, + .node_count = 1}; + + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delta, overlay_generation), CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "needle"), 1); + + ASSERT_EQ(cbm_store_delete_project(s, "test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "needle"), 0); + + int count = -1; + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", NULL, &count), CBM_STORE_OK); + ASSERT_EQ(count, 0); + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_file_delta_publish_rejects_invalid_delta_without_rows) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + + cbm_node_t bad_node = {.project = "test", + .label = "Function", + .name = "bad", + .qualified_name = NULL, + .file_path = "bad.go", + .properties_json = "{}"}; + cbm_store_file_delta_t bad_delta = {.project = "test", + .rel_path = "bad.go", + .generation = BASE_GENERATION, + .nodes = &bad_node, + .node_count = 1}; + + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &bad_delta, overlay_generation), + CBM_STORE_ERR); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "bad.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_EDGES, "test", overlay_generation, + "bad.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "bad.go"), + 0); + int count = -1; + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", + CBM_STORE_OVERLAY_STATUS_RESERVED, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_file_delta_batch_rolls_back_all_files) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + + cbm_node_t good_node = {.project = "test", + .label = "Function", + .name = "good", + .qualified_name = "test.good", + .file_path = "good.go", + .properties_json = "{}"}; + cbm_store_file_delta_t good_delta = {.project = "test", + .rel_path = "good.go", + .generation = BASE_GENERATION, + .nodes = &good_node, + .node_count = 1}; + cbm_node_t bad_node = {.project = "test", + .label = "Function", + .name = "bad", + .qualified_name = NULL, + .file_path = "bad.go", + .properties_json = "{}"}; + cbm_store_file_delta_t bad_delta = {.project = "test", + .rel_path = "bad.go", + .generation = BASE_GENERATION, + .nodes = &bad_node, + .node_count = 1}; + const cbm_store_file_delta_t *deltas[] = {&good_delta, &bad_delta}; + + ASSERT_EQ(cbm_store_publish_overlay_file_delta_batch(s, deltas, CBM_SZ_2, + overlay_generation), + CBM_STORE_ERR); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "good.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "good.go"), + 0); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", overlay_generation, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_RESERVED), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_file_delta_publish_rejects_failed_generation) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_set_overlay_generation_status(s, "test", overlay_generation, + CBM_STORE_OVERLAY_STATUS_FAILED), + CBM_STORE_OK); + + cbm_node_t node = {.project = "test", + .label = "Function", + .name = "blocked", + .qualified_name = "test.blocked", + .file_path = "blocked.go", + .properties_json = "{}"}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "blocked.go", + .generation = BASE_GENERATION, + .nodes = &node, + .node_count = 1}; + + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delta, overlay_generation), + CBM_STORE_ERR); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "blocked.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "blocked.go"), + 0); + + cbm_store_close(s); + PASS(); +} + +typedef struct { + bool saw_active_node_candidates; + bool saw_direct_canonical_count; +} overlay_summary_sql_trace_t; + +static int overlay_summary_sql_trace(unsigned trace_type, void *context, void *statement, + void *sql_text) { + (void)statement; + if (trace_type != SQLITE_TRACE_STMT || !context || !sql_text) { + return 0; + } + overlay_summary_sql_trace_t *trace = context; + const char *sql = sql_text; + if (strstr(sql, "active_node_candidates")) { + trace->saw_active_node_candidates = true; + } + if (strstr(sql, "SELECT COUNT(*) FROM nodes WHERE project")) { + trace->saw_direct_canonical_count = true; + } + return 0; +} + +TEST(store_overlay_node_view_summary_counts_latest_ready_overlay) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &old_main), 0); + ASSERT_GT(cbm_store_upsert_node(s, &stable), 0); + + overlay_summary_sql_trace_t trace = {0}; + sqlite3 *db = cbm_store_get_db(s); + ASSERT_NOT_NULL(db); + ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_STMT, overlay_summary_sql_trace, &trace), + SQLITE_OK); + + cbm_store_overlay_node_view_summary_t summary = {0}; + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_FALSE(trace.saw_active_node_candidates); + ASSERT_TRUE(trace.saw_direct_canonical_count); + ASSERT_EQ(summary.overlay_ready_generations, 0); + ASSERT_EQ(summary.active_file_tombstones, 0); + ASSERT_EQ(summary.canonical_nodes_visible, 2); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 0); + ASSERT_EQ(summary.total_nodes_visible, 2); + ASSERT_EQ(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK); + + int64_t first_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &first_overlay), + CBM_STORE_OK); + cbm_node_t first_nodes[] = { + {.project = "test", + .label = "Function", + .name = "new_main", + .qualified_name = "test.new_main", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "new_helper", + .qualified_name = "test.new_helper", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t first_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = first_nodes, + .node_count = 2}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &first_delta, first_overlay), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 1); + ASSERT_EQ(summary.active_file_tombstones, 1); + ASSERT_EQ(summary.canonical_nodes_visible, 1); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 2); + ASSERT_EQ(summary.total_nodes_visible, 3); + + int64_t second_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &second_overlay), + CBM_STORE_OK); + cbm_node_t second_node = {.project = "test", + .label = "Function", + .name = "newer_main", + .qualified_name = "test.newer_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_file_delta_t second_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &second_node, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &second_delta, second_overlay), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 1); + ASSERT_EQ(summary.active_file_tombstones, 1); + ASSERT_EQ(summary.canonical_nodes_visible, 1); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 1); + ASSERT_EQ(summary.total_nodes_visible, 2); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", first_overlay, + "main.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + first_overlay, "main.go"), + 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_additions_keep_canonical_file_rows_visible) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "main.h", + .start_line = 1, + .end_line = 3, + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &stable), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t overlay_nodes[] = { + {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "main.h", + .start_line = 1, + .end_line = 6, + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "added", + .qualified_name = "test.added", + .file_path = "main.h", + .start_line = 8, + .end_line = 10, + .properties_json = "{}"}, + }; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.h", + .generation = BASE_GENERATION, + .nodes = overlay_nodes, + .node_count = 2}; + const cbm_store_file_delta_t *deltas[] = {&delta}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta_additions_batch(s, deltas, 1, + overlay_generation), + CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "main.h"), + 0); + + cbm_store_overlay_node_view_summary_t summary = {0}; + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &summary), CBM_STORE_OK); + ASSERT_EQ(summary.overlay_ready_generations, 1); + ASSERT_EQ(summary.active_file_tombstones, 0); + ASSERT_EQ(summary.canonical_nodes_visible, 0); + ASSERT_EQ(summary.overlay_owned_nodes_visible, 2); + ASSERT_EQ(summary.total_nodes_visible, 2); + + cbm_node_t found = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn_overlay_view(s, "test", "test.stable", &found), + CBM_STORE_OK); + ASSERT_STR_EQ(found.file_path, "main.h"); + ASSERT_EQ(found.end_line, 6); + cbm_node_free_fields(&found); + ASSERT_EQ(cbm_store_find_node_by_qn_overlay_view(s, "test", "test.added", &found), + CBM_STORE_OK); + ASSERT_STR_EQ(found.file_path, "main.h"); + ASSERT_EQ(found.start_line, 8); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +TEST(store_overlay_publish_prunes_superseded_file_rows_and_fts) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t first_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &first_overlay), + CBM_STORE_OK); + cbm_node_t stale_main = {.project = "test", + .label = "Function", + .name = "stale_symbol", + .qualified_name = "test.stale_symbol", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t helper = {.project = "test", + .label = "Function", + .name = "helper_symbol", + .qualified_name = "test.helper_symbol", + .file_path = "helper.go", + .properties_json = "{}"}; + cbm_store_file_delta_t first_main = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &stale_main, + .node_count = 1}; + cbm_store_file_delta_t first_helper = {.project = "test", + .rel_path = "helper.go", + .generation = BASE_GENERATION, + .nodes = &helper, + .node_count = 1}; + const cbm_store_file_delta_t *first_deltas[] = {&first_main, &first_helper}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta_batch(s, first_deltas, CBM_SZ_2, + first_overlay), + CBM_STORE_OK); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "stale"), 1); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "helper"), 1); + + int64_t second_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &second_overlay), + CBM_STORE_OK); + cbm_node_t fresh_main = {.project = "test", + .label = "Function", + .name = "fresh_symbol", + .qualified_name = "test.fresh_symbol", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_file_delta_t second_main = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &fresh_main, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &second_main, second_overlay), + CBM_STORE_OK); + + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", first_overlay, + "main.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + first_overlay, "main.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", first_overlay, + "helper.go"), + 1); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + first_overlay, "helper.go"), + 1); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "stale"), 0); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "helper"), 1); + ASSERT_EQ(store_count_overlay_fts_raw_matches(s, "fresh"), 1); + + int count = -1; + ASSERT_EQ(cbm_store_count_overlay_generations(s, "test", CBM_STORE_OVERLAY_STATUS_READY, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", first_overlay, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", second_overlay, BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_compact_overlay_generation_promotes_metadata_and_cleans_overlay) { + enum { BASE_GENERATION = 1, COMPACT_GENERATION = 2 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_nodes[1] = {{.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t edges[1] = {{.source_qn = "test.main.New", + .target_qn = "test.helper.Helper", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}}; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "main.go", + .sha256 = "overlay-main-hash", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "overlay-main-content", + .git_oid = "overlay-oid", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-overlay", + .generation = BASE_GENERATION, + .indexed_at = "2026-06-30T00:02:00Z"}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.main.New", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_import_ref_t imports[1] = {{.import_text = "test.helper", + .local_name = "Helper", + .target_qn = "test.helper.Helper"}}; + cbm_store_file_delta_t overlay_delta = { + .project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .file_hash = &hash, + .file_state = &state, + .nodes = new_nodes, + .node_count = 1, + .edges = edges, + .edge_count = 1, + .exports = exports, + .export_count = 1, + .imports = imports, + .import_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &overlay_delta, overlay_generation), + CBM_STORE_OK); + cbm_dirty_file_state_t dirty = {.project = "test", + .rel_path = "main.go", + .observed_hash = "overlay-main-content", + .observed_generation = overlay_generation, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_OVERLAY_READY}; + ASSERT_EQ(cbm_store_upsert_dirty_file(s, &dirty), CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, COMPACT_GENERATION); + int64_t claimed_overlay = 0; + int64_t claimed_base = 0; + ASSERT_EQ(cbm_store_claim_ready_overlay_generation(s, "test", &claimed_overlay, + &claimed_base), + CBM_STORE_OK); + ASSERT_EQ(claimed_overlay, overlay_generation); + ASSERT_EQ(claimed_base, BASE_GENERATION); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", overlay_generation, + BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + cbm_store_overlay_node_view_summary_t claimed_summary = {0}; + ASSERT_EQ(cbm_store_get_overlay_node_view_summary(s, "test", &claimed_summary), + CBM_STORE_OK); + ASSERT_EQ(claimed_summary.overlay_ready_generations, 1); + ASSERT_EQ(claimed_summary.active_file_tombstones, 1); + ASSERT_EQ(claimed_summary.overlay_owned_nodes_visible, 1); + cbm_node_t *claimed_nodes = NULL; + int claimed_count = -1; + ASSERT_EQ(cbm_store_find_nodes_by_file_overlay_view(s, "test", "main.go", + &claimed_nodes, &claimed_count), + CBM_STORE_OK); + ASSERT_EQ(claimed_count, 1); + ASSERT_EQ(claimed_nodes[0].id, CBM_STORE_NO_NODE_ID); + ASSERT_STR_EQ(claimed_nodes[0].qualified_name, "test.main.New"); + cbm_store_free_nodes(claimed_nodes, claimed_count); + ASSERT_EQ(cbm_store_compact_overlay_generation(s, "test", overlay_generation, generation), + CBM_STORE_OK); + int recovered_after_compact = -1; + ASSERT_EQ(cbm_store_recover_overlay_compaction_claims(s, "test", + &recovered_after_compact), + CBM_STORE_OK); + ASSERT_EQ(recovered_after_compact, 0); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 1); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "overlay-main-content"); + ASSERT_STR_EQ(got.pass_fingerprint, "pass-overlay"); + ASSERT_EQ(got.generation, COMPACT_GENERATION); + cbm_store_file_state_free_fields(&got); + + char **items = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "main.go", &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "test.main.New"); + store_free_string_array(items, count); + items = NULL; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.helper.Helper", + &items, &count), + CBM_STORE_OK); + ASSERT_EQ(store_string_array_contains(items, count, "main.go"), 1); + store_free_string_array(items, count); + + int pending = -1; + int overlay_ready = -1; + ASSERT_EQ(cbm_store_count_dirty_files(s, "test", &pending, &overlay_ready), CBM_STORE_OK); + ASSERT_EQ(pending, 0); + ASSERT_EQ(overlay_ready, 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_NODES, "test", overlay_generation, + "main.go"), + 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "main.go"), + 0); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", overlay_generation, + BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 0); + ASSERT_EQ(store_count_index_generation(s, "test", COMPACT_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "", "", + STORE_TEST_COMPLETED_SET), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_compact_overlay_generation_promotes_delete_only_tombstone) { + enum { BASE_GENERATION = 1, COMPACT_GENERATION = 2 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_store_file_delta_t delete_delta = { + .project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delete_delta, overlay_generation), + CBM_STORE_OK); + cbm_dirty_file_state_t dirty = {.project = "test", + .rel_path = "main.go", + .observed_generation = overlay_generation, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_OVERLAY_READY}; + ASSERT_EQ(cbm_store_upsert_dirty_file(s, &dirty), CBM_STORE_OK); + + int64_t compacted_overlay = 0; + int64_t compact_generation = 0; + ASSERT_EQ(cbm_store_compact_next_overlay_generation(s, "test", &compacted_overlay, + &compact_generation), + CBM_STORE_OK); + ASSERT_EQ(compacted_overlay, overlay_generation); + ASSERT_EQ(compact_generation, COMPACT_GENERATION); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 0); + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &got), CBM_STORE_NOT_FOUND); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODES_FTS, + COMPACT_GENERATION, + CBM_STORE_DERIVED_STATUS_STALE), + 1); + int pending = -1; + int overlay_ready = -1; + ASSERT_EQ(cbm_store_count_dirty_files(s, "test", &pending, &overlay_ready), CBM_STORE_OK); + ASSERT_EQ(pending, 0); + ASSERT_EQ(overlay_ready, 0); + ASSERT_EQ(store_count_overlay_rows(s, STORE_TEST_OVERLAY_TOMBSTONES, "test", + overlay_generation, "main.go"), + 0); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", overlay_generation, + BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 0); + ASSERT_EQ(store_count_index_generation(s, "test", COMPACT_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "", "", + STORE_TEST_COMPLETED_SET), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_compact_ready_overlay_generations_respects_batch_limit) { + enum { BASE_GENERATION = 1, COMPACT_ONE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + int64_t first_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &first_overlay), + CBM_STORE_OK); + cbm_store_file_delta_t delete_main = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .derived_view_name = + CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = + CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delete_main, first_overlay), + CBM_STORE_OK); + + int64_t second_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &second_overlay), + CBM_STORE_OK); + cbm_store_file_delta_t delete_helper = {.project = "test", + .rel_path = "helper.go", + .generation = BASE_GENERATION, + .derived_view_name = + CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = + CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delete_helper, second_overlay), + CBM_STORE_OK); + + int compacted = -1; + ASSERT_EQ(cbm_store_compact_ready_overlay_generations( + s, "test", COMPACT_ONE_GENERATION, &compacted), + CBM_STORE_OK); + ASSERT_EQ(compacted, 1); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", first_overlay, + BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 0); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", second_overlay, + BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + + compacted = -1; + ASSERT_EQ(cbm_store_compact_ready_overlay_generations( + s, "test", CBM_STORE_COMPACT_ALL_GENERATIONS, &compacted), + CBM_STORE_OK); + ASSERT_EQ(compacted, 1); + ASSERT_EQ(store_count_overlay_generation_row(s, "test", second_overlay, + BASE_GENERATION, + CBM_STORE_OVERLAY_STATUS_READY), + 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_find_nodes_by_file_overlay_view_returns_latest_ready_rows) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &old_main), 0); + ASSERT_GT(cbm_store_upsert_node(s, &stable), 0); + + cbm_node_t *nodes = NULL; + int count = -1; + ASSERT_EQ(cbm_store_find_nodes_by_file_overlay_view(s, "test", "main.go", &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_GT(nodes[0].id, CBM_STORE_NO_NODE_ID); + ASSERT_STR_EQ(nodes[0].name, "old_main"); + cbm_store_free_nodes(nodes, count); + + int64_t first_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &first_overlay), + CBM_STORE_OK); + cbm_node_t first_nodes[] = { + {.project = "test", + .label = "Function", + .name = "new_main", + .qualified_name = "test.new_main", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "new_helper", + .qualified_name = "test.new_helper", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_file_delta_t first_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = first_nodes, + .node_count = 2}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &first_delta, first_overlay), + CBM_STORE_OK); + + nodes = NULL; + count = -1; + ASSERT_EQ(cbm_store_find_nodes_by_file_overlay_view(s, "test", "main.go", &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_EQ(nodes[0].id, CBM_STORE_NO_NODE_ID); + ASSERT_EQ(nodes[1].id, CBM_STORE_NO_NODE_ID); + ASSERT_STR_EQ(nodes[0].name, "new_helper"); + ASSERT_STR_EQ(nodes[1].name, "new_main"); + cbm_store_free_nodes(nodes, count); + + int64_t second_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, &second_overlay), + CBM_STORE_OK); + cbm_node_t second_node = {.project = "test", + .label = "Function", + .name = "newer_main", + .qualified_name = "test.newer_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_file_delta_t second_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &second_node, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &second_delta, second_overlay), + CBM_STORE_OK); + + nodes = NULL; + count = -1; + ASSERT_EQ(cbm_store_find_nodes_by_file_overlay_view(s, "test", "main.go", &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_EQ(nodes[0].id, CBM_STORE_NO_NODE_ID); + ASSERT_STR_EQ(nodes[0].name, "newer_main"); + cbm_store_free_nodes(nodes, count); + + nodes = NULL; + count = -1; + ASSERT_EQ(cbm_store_find_nodes_by_file_overlay_view(s, "test", "stable.go", &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_GT(nodes[0].id, CBM_STORE_NO_NODE_ID); + ASSERT_STR_EQ(nodes[0].name, "stable"); + cbm_store_free_nodes(nodes, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_active_overlay_qn_view_uses_source_span_selection) { + enum { BASE_GENERATION = 7 }; + const char *project = "test"; + const char *qn = "test.src.store.store.cbm_store"; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK); + + cbm_node_t source = {.project = project, + .label = "Class", + .name = "cbm_store", + .qualified_name = qn, + .file_path = "src/store/store.c", + .start_line = 146, + .end_line = 211, + .properties_json = "{\"docstring\":\"source\"}"}; + ASSERT_GT(cbm_store_upsert_node(s, &source), 0); + + int64_t header_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, project, BASE_GENERATION, + &header_overlay), + CBM_STORE_OK); + cbm_node_t header_node = {.project = project, + .label = "Class", + .name = "cbm_store", + .qualified_name = qn, + .file_path = "src/store/store.h", + .start_line = 19, + .end_line = 19, + .properties_json = "{}"}; + cbm_store_file_delta_t header_delta = {.project = project, + .rel_path = "src/store/store.h", + .generation = BASE_GENERATION, + .nodes = &header_node, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &header_delta, header_overlay), + CBM_STORE_OK); + + cbm_node_t found = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn_overlay_view(s, project, qn, &found), CBM_STORE_OK); + ASSERT_STR_EQ(found.file_path, "src/store/store.c"); + ASSERT_EQ(found.start_line, 146); + cbm_node_free_fields(&found); + + int64_t source_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, project, BASE_GENERATION, + &source_overlay), + CBM_STORE_OK); + cbm_node_t richer_source = {.project = project, + .label = "Class", + .name = "cbm_store", + .qualified_name = qn, + .file_path = "src/store/store.c", + .start_line = 1, + .end_line = 300, + .properties_json = "{\"docstring\":\"overlay\"}"}; + cbm_store_file_delta_t source_delta = {.project = project, + .rel_path = "src/store/store.c", + .generation = BASE_GENERATION, + .nodes = &richer_source, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &source_delta, source_overlay), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_find_node_by_qn_overlay_view(s, project, qn, &found), CBM_STORE_OK); + ASSERT_STR_EQ(found.file_path, "src/store/store.c"); + ASSERT_EQ(found.start_line, 1); + ASSERT_EQ(found.end_line, 300); + cbm_node_free_fields(&found); + + cbm_store_close(s); + PASS(); +} + +TEST(store_search_overlay_view_without_ready_overlay_matches_canonical_search) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s, &old_main), 0); + + cbm_search_params_t params = {.project = "test", + .pattern = "main", + .sort_by = "name", + .limit = 10}; + cbm_search_output_t active = {0}; + ASSERT_EQ(cbm_store_search_overlay_view(s, ¶ms, &active), CBM_STORE_OK); + ASSERT_EQ(active.total, 1); + ASSERT_EQ(active.count, 1); + ASSERT_GT(active.results[0].node.id, CBM_STORE_NO_NODE_ID); + ASSERT_STR_EQ(active.results[0].node.name, "old_main"); + cbm_store_search_free(&active); + + cbm_store_close(s); + PASS(); +} + +TEST(store_search_overlay_view_matches_full_rebuild_oracle) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *live = cbm_store_open_memory(); + cbm_store_t *oracle = cbm_store_open_memory(); + ASSERT_NOT_NULL(live); + ASSERT_NOT_NULL(oracle); + ASSERT_EQ(cbm_store_upsert_project(live, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(oracle, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(live, &old_main), 0); + ASSERT_GT(cbm_store_upsert_node(live, &stable), 0); + ASSERT_GT(cbm_store_upsert_node(oracle, &stable), 0); + + int64_t first_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(live, "test", BASE_GENERATION, + &first_overlay), + CBM_STORE_OK); + cbm_node_t first_main = {.project = "test", + .label = "Function", + .name = "new_main", + .qualified_name = "test.new_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_file_delta_t first_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &first_main, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(live, &first_delta, first_overlay), + CBM_STORE_OK); + + int64_t second_overlay = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(live, "test", BASE_GENERATION, + &second_overlay), + CBM_STORE_OK); + cbm_node_t newer_main = {.project = "test", + .label = "Function", + .name = "newer_main", + .qualified_name = "test.newer_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_file_delta_t second_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &newer_main, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(live, &second_delta, second_overlay), + CBM_STORE_OK); + ASSERT_GT(cbm_store_upsert_node(oracle, &newer_main), 0); + + cbm_search_params_t params = {.project = "test", + .pattern = "main|stable", + .sort_by = "name", + .limit = 10, + .min_degree = 0}; + cbm_search_output_t active = {0}; + cbm_search_output_t expected = {0}; + ASSERT_EQ(cbm_store_search_overlay_view(live, ¶ms, &active), CBM_STORE_OK); + ASSERT_EQ(cbm_store_search(oracle, ¶ms, &expected), CBM_STORE_OK); + ASSERT_EQ(active.total, expected.total); + ASSERT_EQ(active.count, expected.count); + ASSERT_EQ(active.count, 2); + ASSERT_STR_EQ(active.results[0].node.name, expected.results[0].node.name); + ASSERT_STR_EQ(active.results[1].node.name, expected.results[1].node.name); + ASSERT_STR_EQ(active.results[0].node.name, "newer_main"); + ASSERT_STR_EQ(active.results[1].node.name, "stable"); + ASSERT_EQ(active.results[0].node.id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(active.results[1].node.id, CBM_STORE_NO_NODE_ID); + + cbm_store_search_free(&active); + cbm_store_search_free(&expected); + cbm_store_close(live); + cbm_store_close(oracle); + PASS(); +} + +TEST(store_search_overlay_view_uses_active_relationship_edges) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *live = cbm_store_open_memory(); + cbm_store_t *oracle = cbm_store_open_memory(); + ASSERT_NOT_NULL(live); + ASSERT_NOT_NULL(oracle); + ASSERT_EQ(cbm_store_upsert_project(live, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(oracle, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{}"}; + int64_t old_main_id = cbm_store_upsert_node(live, &old_main); + int64_t stable_id = cbm_store_upsert_node(live, &stable); + ASSERT_GT(old_main_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = "test", + .source_id = old_main_id, + .target_id = stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(live, &old_edge), 0); + ASSERT_GT(cbm_store_upsert_node(oracle, &stable), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(live, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_main = {.project = "test", + .label = "Function", + .name = "new_main", + .qualified_name = "test.new_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "test.new_main", + .target_qn = "test.stable", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &new_main, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(live, &delta, overlay_generation), + CBM_STORE_OK); + int64_t new_main_id = cbm_store_upsert_node(oracle, &new_main); + ASSERT_GT(new_main_id, 0); + int64_t oracle_stable_id = 0; + cbm_node_t oracle_stable = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(oracle, "test", "test.stable", &oracle_stable), + CBM_STORE_OK); + oracle_stable_id = oracle_stable.id; + cbm_node_free_fields(&oracle_stable); + cbm_edge_t oracle_edge = {.project = "test", + .source_id = new_main_id, + .target_id = oracle_stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(oracle, &oracle_edge), 0); + + cbm_search_params_t params = {.project = "test", + .relationship = "CALLS", + .sort_by = "name", + .limit = 10, + .include_connected = true, + .min_degree = 0, + .max_degree = -1}; + cbm_search_output_t active = {0}; + cbm_search_output_t expected = {0}; + ASSERT_EQ(cbm_store_search_overlay_view(live, ¶ms, &active), CBM_STORE_OK); + ASSERT_EQ(cbm_store_search(oracle, ¶ms, &expected), CBM_STORE_OK); + ASSERT_EQ(active.total, expected.total); + ASSERT_EQ(active.count, expected.count); + ASSERT_EQ(active.count, 2); + ASSERT_STR_EQ(active.results[0].node.name, expected.results[0].node.name); + ASSERT_STR_EQ(active.results[1].node.name, expected.results[1].node.name); + ASSERT_STR_EQ(active.results[0].node.name, "new_main"); + ASSERT_STR_EQ(active.results[1].node.name, "stable"); + ASSERT_EQ(active.results[0].node.id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(active.results[1].in_degree, 0); + ASSERT_GT(active.results[0].out_degree, 0); + ASSERT_EQ(active.results[0].connected_count, 1); + ASSERT_EQ(active.results[1].connected_count, 1); + ASSERT_STR_EQ(active.results[0].connected_names[0], "stable"); + ASSERT_STR_EQ(active.results[1].connected_names[0], "new_main"); + + cbm_node_t *active_names = NULL; + int active_name_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_name_overlay_view(live, "test", "new_main", + &active_names, &active_name_count), + CBM_STORE_OK); + ASSERT_EQ(active_name_count, 1); + ASSERT_STR_EQ(active_names[0].qualified_name, "test.new_main"); + cbm_store_free_nodes(active_names, active_name_count); + active_names = NULL; + active_name_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_name_overlay_view(live, "test", "old_main", + &active_names, &active_name_count), + CBM_STORE_OK); + ASSERT_EQ(active_name_count, 0); + cbm_store_free_nodes(active_names, active_name_count); + + cbm_node_t *active_suffix = NULL; + int active_suffix_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qn_suffix_overlay_view( + live, "test", "new_main", &active_suffix, &active_suffix_count), + CBM_STORE_OK); + ASSERT_EQ(active_suffix_count, 1); + ASSERT_STR_EQ(active_suffix[0].qualified_name, "test.new_main"); + cbm_store_free_nodes(active_suffix, active_suffix_count); + active_suffix = NULL; + active_suffix_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qn_suffix_overlay_view( + live, "test", "old_main", &active_suffix, &active_suffix_count), + CBM_STORE_OK); + ASSERT_EQ(active_suffix_count, 0); + cbm_store_free_nodes(active_suffix, active_suffix_count); + + int active_in_degree = 0; + int active_out_degree = 0; + ASSERT_EQ(cbm_store_active_node_degree_by_qn(live, "test", "test.new_main", + &active_in_degree, &active_out_degree), + CBM_STORE_OK); + ASSERT_EQ(active_in_degree, 0); + ASSERT_EQ(active_out_degree, 1); + + char **active_callers = NULL; + int active_caller_count = 0; + char **active_callees = NULL; + int active_callee_count = 0; + ASSERT_EQ(cbm_store_active_node_neighbor_names_by_qn( + live, "test", "test.new_main", 1, &active_callers, + &active_caller_count, &active_callees, &active_callee_count), + CBM_STORE_OK); + ASSERT_EQ(active_caller_count, 0); + ASSERT_EQ(active_callee_count, 1); + ASSERT_STR_EQ(active_callees[0], "stable"); + free(active_callers); + free(active_callees[0]); + free(active_callees); + + cbm_node_t *active_functions = NULL; + int active_function_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label_overlay_view(live, "test", "Function", + &active_functions, + &active_function_count), + CBM_STORE_OK); + ASSERT_EQ(active_function_count, 2); + ASSERT_STR_EQ(active_functions[0].name, "new_main"); + ASSERT_STR_EQ(active_functions[1].name, "stable"); + cbm_store_free_nodes(active_functions, active_function_count); + active_functions = NULL; + active_function_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label_overlay_view(live, "test", NULL, &active_functions, + &active_function_count), + CBM_STORE_OK); + ASSERT_EQ(active_function_count, 2); + cbm_store_free_nodes(active_functions, active_function_count); + active_functions = NULL; + active_function_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label_overlay_view_limited(live, "test", NULL, 1, + &active_functions, + &active_function_count), + CBM_STORE_OK); + ASSERT_EQ(active_function_count, 1); + cbm_store_free_nodes(active_functions, active_function_count); + + enum { EDGE_TYPE_COUNT_BEYOND_LEGACY_CAP = 17 }; + const char *edge_types[EDGE_TYPE_COUNT_BEYOND_LEGACY_CAP] = { + "NONMATCHING_00", "NONMATCHING_01", "NONMATCHING_02", "NONMATCHING_03", "NONMATCHING_04", + "NONMATCHING_05", "NONMATCHING_06", "NONMATCHING_07", "NONMATCHING_08", "NONMATCHING_09", + "NONMATCHING_10", "NONMATCHING_11", "NONMATCHING_12", "NONMATCHING_13", "NONMATCHING_14", + "NONMATCHING_15", "CALLS", + }; + + /* Every traversal consumer must honor the complete requested type set. + * The only matching type is deliberately beyond the former 16-type cap. + * JSON binding keeps SQL text/bind count O(1); matching remains O(T + E) + * for T total type-name bytes and the edges visited by SQLite. */ + cbm_traverse_result_t canonical_trace = {0}; + ASSERT_EQ(cbm_store_bfs(live, old_main_id, "outbound", edge_types, + EDGE_TYPE_COUNT_BEYOND_LEGACY_CAP, 1, 10, &canonical_trace), + CBM_STORE_OK); + ASSERT_EQ(canonical_trace.visited_count, 1); + ASSERT_STR_EQ(canonical_trace.visited[0].node.name, "stable"); + cbm_store_traverse_free(&canonical_trace); + + cbm_store_edge_node_t *active_edge_nodes = NULL; + int active_edge_node_count = 0; + ASSERT_EQ(cbm_store_find_active_edge_nodes_by_qn( + live, "test", "test.new_main", edge_types, EDGE_TYPE_COUNT_BEYOND_LEGACY_CAP, + CBM_STORE_EDGE_DIR_OUTBOUND, &active_edge_nodes, &active_edge_node_count), + CBM_STORE_OK); + ASSERT_EQ(active_edge_node_count, 1); + ASSERT_STR_EQ(active_edge_nodes[0].node.name, "stable"); + cbm_store_free_edge_nodes(active_edge_nodes, active_edge_node_count); + + cbm_traverse_result_t active_trace = {0}; + ASSERT_EQ(cbm_store_bfs_overlay_view(live, "test", "test.new_main", "outbound", edge_types, + EDGE_TYPE_COUNT_BEYOND_LEGACY_CAP, 1, 10, &active_trace), + CBM_STORE_OK); + ASSERT_EQ(active_trace.visited_count, 1); + ASSERT_STR_EQ(active_trace.root.name, "new_main"); + ASSERT_STR_EQ(active_trace.visited[0].node.name, "stable"); + cbm_store_traverse_free(&active_trace); + + cbm_traverse_result_t multi_trace = {0}; + bool multi_truncated = true; + ASSERT_EQ(cbm_store_bfs_multi(live, &old_main_id, 1, "outbound", edge_types, + EDGE_TYPE_COUNT_BEYOND_LEGACY_CAP, 1, 10, &multi_trace, + &multi_truncated), + CBM_STORE_OK); + ASSERT_FALSE(multi_truncated); + ASSERT_EQ(multi_trace.visited_count, 1); + ASSERT_STR_EQ(multi_trace.visited[0].node.name, "stable"); + cbm_store_traverse_free(&multi_trace); + + cbm_store_search_free(&active); + cbm_store_search_free(&expected); + cbm_store_close(live); + cbm_store_close(oracle); + PASS(); +} + +TEST(store_search_overlay_view_dedupes_multi_owner_active_edges) { + enum { + BASE_GENERATION = 1, + EXPECTED_ACTIVE_NODES = 2, + EXPECTED_LOGICAL_EDGES = 1, + SEARCH_LIMIT = 10, + }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + + cbm_node_t main_node = {.project = "test", + .label = "Function", + .name = "main", + .qualified_name = "test.main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_delta_edge_t main_edge = {.source_qn = "test.main", + .target_qn = "test.helper", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t main_delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &main_node, + .node_count = 1, + .edges = &main_edge, + .edge_count = 1}; + cbm_node_t helper_node = {.project = "test", + .label = "Function", + .name = "helper", + .qualified_name = "test.helper", + .file_path = "helper.go", + .properties_json = "{}"}; + cbm_store_delta_edge_t helper_edge = {.source_qn = "test.main", + .target_qn = "test.helper", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t helper_delta = {.project = "test", + .rel_path = "helper.go", + .generation = BASE_GENERATION, + .nodes = &helper_node, + .node_count = 1, + .edges = &helper_edge, + .edge_count = 1}; + const cbm_store_file_delta_t *deltas[] = {&main_delta, &helper_delta}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta_batch(s, deltas, CBM_SZ_2, + overlay_generation), + CBM_STORE_OK); + + cbm_search_params_t params = {.project = "test", + .relationship = "CALLS", + .sort_by = "name", + .limit = SEARCH_LIMIT, + .min_degree = 0, + .max_degree = -1}; + cbm_search_output_t active = {0}; + ASSERT_EQ(cbm_store_search_overlay_view(s, ¶ms, &active), CBM_STORE_OK); + ASSERT_EQ(active.total, EXPECTED_ACTIVE_NODES); + ASSERT_EQ(active.count, EXPECTED_ACTIVE_NODES); + ASSERT_STR_EQ(active.results[0].node.name, "helper"); + ASSERT_EQ(active.results[0].in_degree, EXPECTED_LOGICAL_EDGES); + ASSERT_EQ(active.results[0].out_degree, 0); + ASSERT_STR_EQ(active.results[1].node.name, "main"); + ASSERT_EQ(active.results[1].in_degree, 0); + ASSERT_EQ(active.results[1].out_degree, EXPECTED_LOGICAL_EDGES); + cbm_store_search_free(&active); + + cbm_schema_info_t schema = {0}; + ASSERT_EQ(cbm_store_get_schema_counts_overlay_view(s, "test", &schema), CBM_STORE_OK); + ASSERT_EQ(schema.edge_type_count, EXPECTED_LOGICAL_EDGES); + ASSERT_STR_EQ(schema.edge_types[0].type, "CALLS"); + ASSERT_EQ(schema.edge_types[0].count, EXPECTED_LOGICAL_EDGES); + ASSERT_EQ(schema.rel_pattern_count, EXPECTED_LOGICAL_EDGES); + ASSERT_STR_EQ(schema.rel_patterns[0].source_label, "Function"); + ASSERT_STR_EQ(schema.rel_patterns[0].edge_type, "CALLS"); + ASSERT_STR_EQ(schema.rel_patterns[0].target_label, "Function"); + ASSERT_EQ(schema.rel_patterns[0].observed_count, 1); + cbm_store_schema_free(&schema); + + cbm_store_close(s); + PASS(); +} + +TEST(store_schema_counts_overlay_view_uses_active_nodes_and_edges) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{\"old_role\":true}"}; + cbm_node_t stable = {.project = "test", + .label = "Class", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{\"stable_role\":true}"}; + int64_t old_main_id = cbm_store_upsert_node(s, &old_main); + int64_t stable_id = cbm_store_upsert_node(s, &stable); + ASSERT_GT(old_main_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = "test", + .source_id = old_main_id, + .target_id = stable_id, + .type = "CALLS", + .properties_json = "{\"old_edge\":true}"}; + ASSERT_GT(cbm_store_insert_edge(s, &old_edge), 0); + + cbm_schema_info_t schema = {0}; + ASSERT_EQ(cbm_store_get_schema_counts(s, "test", &schema), CBM_STORE_OK); + ASSERT_EQ(schema.rel_pattern_count, 1); + ASSERT_STR_EQ(schema.rel_patterns[0].source_label, "Function"); + ASSERT_STR_EQ(schema.rel_patterns[0].edge_type, "CALLS"); + ASSERT_STR_EQ(schema.rel_patterns[0].target_label, "Class"); + ASSERT_EQ(schema.rel_patterns[0].observed_count, 1); + cbm_store_schema_free(&schema); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(s, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_main = {.project = "test", + .label = "Route", + .name = "/fresh", + .qualified_name = "test.route.fresh", + .file_path = "main.go", + .properties_json = "{\"fresh_role\":true}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "test.route.fresh", + .target_qn = "test.stable", + .type = "HANDLES", + .properties_json = "{\"fresh_edge\":true}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &new_main, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(s, &delta, overlay_generation), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_get_schema_counts_overlay_view(s, "test", &schema), CBM_STORE_OK); + ASSERT_EQ(schema.node_label_count, 2); + ASSERT_EQ(schema.edge_type_count, 1); + int route_count = CBM_NOT_FOUND; + int class_count = CBM_NOT_FOUND; + int function_count = CBM_NOT_FOUND; + for (int i = 0; i < schema.node_label_count; i++) { + if (strcmp(schema.node_labels[i].label, "Route") == 0) { + route_count = schema.node_labels[i].count; + } else if (strcmp(schema.node_labels[i].label, "Class") == 0) { + class_count = schema.node_labels[i].count; + } else if (strcmp(schema.node_labels[i].label, "Function") == 0) { + function_count = schema.node_labels[i].count; + } + } + int handles_count = CBM_NOT_FOUND; + int calls_count = CBM_NOT_FOUND; + for (int i = 0; i < schema.edge_type_count; i++) { + if (strcmp(schema.edge_types[i].type, "HANDLES") == 0) { + handles_count = schema.edge_types[i].count; + } else if (strcmp(schema.edge_types[i].type, "CALLS") == 0) { + calls_count = schema.edge_types[i].count; + } + } + ASSERT_EQ(route_count, 1); + ASSERT_EQ(class_count, 1); + ASSERT_EQ(function_count, CBM_NOT_FOUND); + ASSERT_EQ(handles_count, 1); + ASSERT_EQ(calls_count, CBM_NOT_FOUND); + ASSERT_EQ(schema.rel_pattern_count, 1); + ASSERT_STR_EQ(schema.rel_patterns[0].source_label, "Route"); + ASSERT_STR_EQ(schema.rel_patterns[0].edge_type, "HANDLES"); + ASSERT_STR_EQ(schema.rel_patterns[0].target_label, "Class"); + ASSERT_EQ(schema.rel_patterns[0].observed_count, 1); + cbm_store_schema_free(&schema); + + ASSERT_EQ(cbm_store_get_schema_overlay_view(s, "test", &schema), CBM_STORE_OK); + bool saw_fresh_node_prop = false; + bool saw_old_node_prop = false; + bool saw_stable_node_prop = false; + for (int i = 0; i < schema.node_label_count; i++) { + for (int j = 0; j < schema.node_labels[i].property_count; j++) { + const char *prop = schema.node_labels[i].properties[j]; + if (strcmp(schema.node_labels[i].label, "Route") == 0 && + strcmp(prop, "fresh_role") == 0) { + saw_fresh_node_prop = true; + } + if (strcmp(prop, "old_role") == 0) { + saw_old_node_prop = true; + } + if (strcmp(schema.node_labels[i].label, "Class") == 0 && + strcmp(prop, "stable_role") == 0) { + saw_stable_node_prop = true; + } + } + } + bool saw_fresh_edge_prop = false; + bool saw_old_edge_prop = false; + for (int i = 0; i < schema.edge_type_count; i++) { + for (int j = 0; j < schema.edge_types[i].property_count; j++) { + const char *prop = schema.edge_types[i].properties[j]; + if (strcmp(schema.edge_types[i].type, "HANDLES") == 0 && + strcmp(prop, "fresh_edge") == 0) { + saw_fresh_edge_prop = true; + } + if (strcmp(prop, "old_edge") == 0) { + saw_old_edge_prop = true; + } + } + } + ASSERT(saw_fresh_node_prop); + ASSERT(saw_stable_node_prop); + ASSERT(!saw_old_node_prop); + ASSERT(saw_fresh_edge_prop); + ASSERT(!saw_old_edge_prop); + ASSERT_EQ(schema.rel_pattern_count, 1); + ASSERT_STR_EQ(schema.rel_patterns[0].source_label, "Route"); + ASSERT_STR_EQ(schema.rel_patterns[0].edge_type, "HANDLES"); + ASSERT_STR_EQ(schema.rel_patterns[0].target_label, "Class"); + ASSERT_EQ(schema.rel_patterns[0].observed_count, 1); + cbm_store_schema_free(&schema); + + cbm_store_close(s); + PASS(); +} + +TEST(store_owner_metadata_crud) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t nodes[2] = { + {.project = "test", + .label = "Function", + .name = "main", + .qualified_name = "test.main", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "helper", + .qualified_name = "test.helper", + .file_path = "helper.go", + .properties_json = "{}"}, + }; + int64_t main_id = cbm_store_upsert_node(s, &nodes[0]); + int64_t helper_id = cbm_store_upsert_node(s, &nodes[1]); + ASSERT_GT(main_id, 0); + ASSERT_GT(helper_id, 0); + + cbm_edge_t edge = {.project = "test", + .source_id = main_id, + .target_id = helper_id, + .type = "CALLS", + .properties_json = "{}"}; + int64_t edge_id = cbm_store_insert_edge(s, &edge); + ASSERT_GT(edge_id, 0); + + ASSERT_EQ(cbm_store_upsert_node_owner(s, "test", main_id, "main.go", 1), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_edge_owner(s, "test", edge_id, "main.go", NULL, 1), + CBM_STORE_OK); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 1); + + ASSERT_EQ(cbm_store_upsert_node_owner(s, "test", main_id, "renamed.go", 2), + CBM_STORE_OK); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "renamed.go"), 1); + + ASSERT_EQ(cbm_store_delete_edge_owners_by_file(s, "test", "main.go"), CBM_STORE_OK); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "renamed.go"), 1); + + ASSERT_EQ(cbm_store_delete_node_owners_by_file(s, "test", "renamed.go"), CBM_STORE_OK); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "renamed.go"), 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_rebuild_file_delta_owners_derives_from_graph) { + enum { TEST_GENERATION = 7 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t nodes[7] = { + {.project = "test", + .label = "Function", + .name = "main", + .qualified_name = "test.main", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "helper", + .qualified_name = "test.helper", + .file_path = "helper.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Package", + .name = "pkg", + .qualified_name = "test.pkg", + .file_path = "", + .properties_json = "{}"}, + {.project = "test", + .label = "File", + .name = "main.go", + .qualified_name = "test.main.__file__", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "File", + .name = "helper.go", + .qualified_name = "test.helper.__file__", + .file_path = "helper.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Folder", + .name = "src", + .qualified_name = "test.src", + .file_path = "src", + .properties_json = "{}"}, + {.project = "test", + .label = "File", + .name = "main.go", + .qualified_name = "test.src.main.__file__", + .file_path = "src/main.go", + .properties_json = "{}"}, + }; + int64_t main_id = cbm_store_upsert_node(s, &nodes[0]); + int64_t helper_id = cbm_store_upsert_node(s, &nodes[1]); + int64_t package_id = cbm_store_upsert_node(s, &nodes[2]); + int64_t main_file_id = cbm_store_upsert_node(s, &nodes[3]); + int64_t helper_file_id = cbm_store_upsert_node(s, &nodes[4]); + int64_t folder_id = cbm_store_upsert_node(s, &nodes[5]); + int64_t nested_file_id = cbm_store_upsert_node(s, &nodes[6]); + ASSERT_GT(main_id, 0); + ASSERT_GT(helper_id, 0); + ASSERT_GT(package_id, 0); + ASSERT_GT(main_file_id, 0); + ASSERT_GT(helper_file_id, 0); + ASSERT_GT(folder_id, 0); + ASSERT_GT(nested_file_id, 0); + + cbm_edge_t direct_edge = {.project = "test", + .source_id = main_id, + .target_id = helper_id, + .type = "CALLS", + .properties_json = "{}"}; + cbm_edge_t target_fallback_edge = {.project = "test", + .source_id = package_id, + .target_id = helper_id, + .type = "CONTAINS", + .properties_json = "{}"}; + cbm_edge_t structural_edge = {.project = "test", + .source_id = folder_id, + .target_id = nested_file_id, + .type = "CONTAINS_FILE", + .properties_json = "{}"}; + int64_t direct_edge_id = cbm_store_insert_edge(s, &direct_edge); + int64_t fallback_edge_id = cbm_store_insert_edge(s, &target_fallback_edge); + int64_t structural_edge_id = cbm_store_insert_edge(s, &structural_edge); + ASSERT_GT(direct_edge_id, 0); + ASSERT_GT(fallback_edge_id, 0); + ASSERT_GT(structural_edge_id, 0); + + cbm_file_state_t same_stem_c_state = {.project = "test", + .rel_path = "src/pipeline/pipeline.c", + .content_hash = "hash-c", + .git_oid = "", + .mtime_ns = 1, + .size = 2, + .language = "C", + .pass_fingerprint = "test", + .generation = TEST_GENERATION, + .indexed_at = "2026-07-02T00:00:00Z"}; + cbm_file_state_t same_stem_h_state = {.project = "test", + .rel_path = "src/pipeline/pipeline.h", + .content_hash = "hash-h", + .git_oid = "", + .mtime_ns = 1, + .size = 2, + .language = "C", + .pass_fingerprint = "test", + .generation = TEST_GENERATION, + .indexed_at = "2026-07-02T00:00:00Z"}; + ASSERT_EQ(cbm_store_upsert_file_state(s, &same_stem_c_state), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_file_state(s, &same_stem_h_state), CBM_STORE_OK); + + cbm_node_t same_stem_nodes[] = { + {.project = "test", + .label = "Module", + .name = "src/pipeline/pipeline.c", + .qualified_name = "test.src.pipeline.pipeline", + .file_path = "src/pipeline/pipeline.c", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "cbm_pipeline_run", + .qualified_name = "test.src.pipeline.pipeline.cbm_pipeline_run", + .file_path = "src/pipeline/pipeline.c", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "cbm_pipeline_mode", + .qualified_name = "test.src.pipeline.pipeline.cbm_pipeline_mode", + .file_path = "src/pipeline/pipeline.h", + .properties_json = "{}"}, + /* Historical File-node QN collision: pipeline.c and pipeline.h both + * map to test.src.pipeline.pipeline.__file__; file_state must still + * allow ownership for src/pipeline/pipeline.c. */ + {.project = "test", + .label = "File", + .name = "pipeline.h", + .qualified_name = "test.src.pipeline.pipeline.__file__", + .file_path = "src/pipeline/pipeline.h", + .properties_json = "{}"}, + }; + int64_t same_stem_module_id = cbm_store_upsert_node(s, &same_stem_nodes[0]); + int64_t same_stem_c_fn_id = cbm_store_upsert_node(s, &same_stem_nodes[1]); + int64_t same_stem_h_fn_id = cbm_store_upsert_node(s, &same_stem_nodes[2]); + int64_t same_stem_file_id = cbm_store_upsert_node(s, &same_stem_nodes[3]); + ASSERT_GT(same_stem_module_id, 0); + ASSERT_GT(same_stem_c_fn_id, 0); + ASSERT_GT(same_stem_h_fn_id, 0); + ASSERT_GT(same_stem_file_id, 0); + cbm_edge_t same_stem_call = {.project = "test", + .source_id = same_stem_module_id, + .target_id = same_stem_h_fn_id, + .type = "CALLS", + .properties_json = "{\"confidence\":0.75}"}; + int64_t same_stem_call_id = cbm_store_insert_edge(s, &same_stem_call); + ASSERT_GT(same_stem_call_id, 0); + + ASSERT_EQ(cbm_store_upsert_node_owner(s, "test", main_id, "stale.go", TEST_GENERATION - 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_edge_owner(s, "test", direct_edge_id, "stale.go", NULL, + TEST_GENERATION - 1), + CBM_STORE_OK); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "stale.go"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "stale.go"), 1); + + ASSERT_EQ(cbm_store_rebuild_file_delta_owners(s, "test", TEST_GENERATION), CBM_STORE_OK); + + int node_owners = 0; + int edge_owners = 0; + ASSERT_EQ(cbm_store_count_file_delta_owners(s, "test", "main.go", &node_owners, + &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 2); + ASSERT_EQ(edge_owners, 1); + + ASSERT_EQ(cbm_store_count_file_delta_owners(s, "test", "helper.go", &node_owners, + &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 2); + ASSERT_EQ(edge_owners, 1); + ASSERT_EQ(cbm_store_count_file_delta_owners(s, "test", "src", &node_owners, &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 0); + ASSERT_EQ(edge_owners, 0); + ASSERT_EQ(cbm_store_count_file_delta_owners(s, "test", "src/main.go", &node_owners, + &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 1); + ASSERT_EQ(edge_owners, 1); + cbm_store_inbound_edge_t *inbound = NULL; + int inbound_count = 0; + ASSERT_EQ(cbm_store_list_file_delta_inbound_edges(s, "test", "src/main.go", &inbound, + &inbound_count), + CBM_STORE_OK); + ASSERT_EQ(inbound_count, 1); + ASSERT_STR_EQ(inbound[0].source_qn, "test.src"); + ASSERT_STR_EQ(inbound[0].target_qn, "test.src.main.__file__"); + ASSERT_STR_EQ(inbound[0].type, "CONTAINS_FILE"); + ASSERT_STR_EQ(inbound[0].source_rel_path, ""); + ASSERT_STR_EQ(inbound[0].target_rel_path, "src/main.go"); + ASSERT_STR_EQ(inbound[0].edge_rel_path, "src/main.go"); + cbm_store_free_inbound_edges(inbound, inbound_count); + + ASSERT_EQ(cbm_store_count_file_delta_owners(s, "test", "src/pipeline/pipeline.c", + &node_owners, &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 2); + ASSERT_EQ(edge_owners, 1); + ASSERT_EQ(cbm_store_count_file_delta_owners(s, "test", "src/pipeline/pipeline.h", + &node_owners, &edge_owners), + CBM_STORE_OK); + ASSERT_EQ(node_owners, 2); + ASSERT_EQ(edge_owners, 0); + + inbound = NULL; + inbound_count = 0; + ASSERT_EQ(cbm_store_list_file_delta_inbound_edges(s, "test", "src/pipeline/pipeline.h", + &inbound, &inbound_count), + CBM_STORE_OK); + ASSERT_EQ(inbound_count, 1); + ASSERT_STR_EQ(inbound[0].source_qn, "test.src.pipeline.pipeline"); + ASSERT_STR_EQ(inbound[0].target_qn, "test.src.pipeline.pipeline.cbm_pipeline_mode"); + ASSERT_STR_EQ(inbound[0].type, "CALLS"); + ASSERT_STR_EQ(inbound[0].properties_json, "{\"confidence\":0.75}"); + ASSERT_STR_EQ(inbound[0].source_rel_path, "src/pipeline/pipeline.c"); + ASSERT_STR_EQ(inbound[0].target_rel_path, "src/pipeline/pipeline.h"); + ASSERT_STR_EQ(inbound[0].edge_rel_path, "src/pipeline/pipeline.c"); + cbm_store_free_inbound_edges(inbound, inbound_count); + + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "stale.go"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "stale.go"), 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_import_export_metadata_crud) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t node = {.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.lib.Helper", + .file_path = "lib.go", + .properties_json = "{}"}; + int64_t node_id = cbm_store_upsert_node(s, &node); + ASSERT_GT(node_id, 0); + + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Helper", "lib.go", node_id, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Unresolved", "lib.go", + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + + char **items = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "lib.go", &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(items[0], "test.lib.Helper"); + ASSERT_STR_EQ(items[1], "test.lib.Unresolved"); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Helper", + "test.lib.Helper", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "other.go", "test.other", "Other", + "test.other.Other", 1), + CBM_STORE_OK); + + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Helper", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "caller.go"); + store_free_string_array(items, count); + + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_for_export_file(s, "test", "lib.go", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "caller.go"); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Helper", + "test.lib.Renamed", 2), + CBM_STORE_OK); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Helper", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + store_free_string_array(items, count); + + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Renamed", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "caller.go"); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_delete_import_refs_by_file(s, "test", "caller.go"), CBM_STORE_OK); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Renamed", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_delete_symbol_exports_by_file(s, "test", "lib.go"), CBM_STORE_OK); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "lib.go", &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + store_free_string_array(items, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_affected_paths_from_exports_and_imports) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Kept", "lib.go", + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Removed", "lib.go", + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Kept", + "test.lib.Kept", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "KeptAgain", + "test.lib.Kept", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "removed_user.go", "test.lib", "Removed", + "test.lib.Removed", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "new_user.go", "test.lib", "New", + "test.lib.New", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "unrelated.go", "test.other", "Other", + "test.other.Other", 1), + CBM_STORE_OK); + + const char *new_exports[] = {"test.lib.Kept", "test.lib.New"}; + char **paths = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_file_delta_affected_paths(s, "test", "lib.go", new_exports, 2, + &paths, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 3); + ASSERT_EQ(store_string_array_contains(paths, count, "lib.go"), 1); + ASSERT_EQ(store_string_array_contains(paths, count, "caller.go"), 0); + ASSERT_EQ(store_string_array_contains(paths, count, "removed_user.go"), 1); + ASSERT_EQ(store_string_array_contains(paths, count, "new_user.go"), 1); + ASSERT_EQ(store_string_array_contains(paths, count, "unrelated.go"), 0); + store_free_string_array(paths, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_affected_paths_high_fanout_dedupes) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Hot", "hot.go", + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + + for (int i = 0; i < CBM_SZ_16; i++) { + char rel_path[CBM_SZ_64]; + char local_name[CBM_SZ_64]; + snprintf(rel_path, sizeof(rel_path), "fan_%02d.go", i); + snprintf(local_name, sizeof(local_name), "Hot%d", i); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", rel_path, "test.lib", local_name, + "test.lib.Hot", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", rel_path, "test.lib", "HotDuplicate", + "test.lib.Hot", 1), + CBM_STORE_OK); + } + + char **paths = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_file_delta_affected_paths(s, "test", "hot.go", NULL, 0, &paths, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, CBM_SZ_16 + 1); + ASSERT_EQ(store_string_array_contains(paths, count, "hot.go"), 1); + ASSERT_EQ(store_string_array_contains(paths, count, "fan_00.go"), 1); + ASSERT_EQ(store_string_array_contains(paths, count, "fan_15.go"), 1); + store_free_string_array(paths, count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_publish_rolls_back_on_failure) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_nodes[1] = {{.project = "test", + .label = "Function", + .name = "Old", + .qualified_name = "test.main.Old", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_file_hash_t old_hash = { + .project = "test", .rel_path = "main.go", .sha256 = "old-hash", .mtime_ns = 1, .size = 10}; + cbm_file_state_t old_state = {.project = "test", + .rel_path = "main.go", + .content_hash = "old-content", + .git_oid = "old-oid", + .mtime_ns = 1, + .size = 10, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = 1, + .indexed_at = "2026-06-30T00:00:00Z"}; + cbm_store_symbol_export_t old_exports[1] = { + {.qualified_name = "test.main.Old", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t old_delta = {.project = "test", + .rel_path = "main.go", + .generation = 1, + .file_hash = &old_hash, + .file_state = &old_state, + .nodes = old_nodes, + .node_count = 1, + .exports = old_exports, + .export_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &old_delta), CBM_STORE_OK); + + cbm_node_t new_nodes[1] = {{.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t bad_edges[1] = {{.source_qn = "test.main.New", + .target_qn = "test.main.Missing", + .type = "CALLS", + .properties_json = "{}"}}; + cbm_file_hash_t new_hash = { + .project = "test", .rel_path = "main.go", .sha256 = "new-hash", .mtime_ns = 2, .size = 20}; + cbm_file_state_t new_state = {.project = "test", + .rel_path = "main.go", + .content_hash = "new-content", + .git_oid = "new-oid", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = 2, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_file_delta_t bad_delta = {.project = "test", + .rel_path = "main.go", + .generation = 2, + .file_hash = &new_hash, + .file_state = &new_state, + .nodes = new_nodes, + .node_count = 1, + .edges = bad_edges, + .edge_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &bad_delta), CBM_STORE_NOT_FOUND); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 0); + + cbm_file_hash_t *hashes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_get_file_hashes(s, "test", &hashes, &count), CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(hashes[0].sha256, "old-hash"); + cbm_store_free_file_hashes(hashes, count); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "old-content"); + ASSERT_EQ(got.generation, 1); + cbm_store_file_state_free_fields(&got); + + char **exports = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "main.go", &exports, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(exports[0], "test.main.Old"); + store_free_string_array(exports, count); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODES_FTS, 1, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_publish_repeated_edge_endpoints) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + enum { + REPEATED_ENDPOINT_NODE_COUNT = 3, + REPEATED_ENDPOINT_EDGE_COUNT = 5, + }; + cbm_node_t nodes[REPEATED_ENDPOINT_NODE_COUNT] = { + {.project = "test", + .label = "Function", + .name = "A", + .qualified_name = "test.main.A", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "B", + .qualified_name = "test.main.B", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "C", + .qualified_name = "test.main.C", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_delta_edge_t edges[REPEATED_ENDPOINT_EDGE_COUNT] = { + {.source_qn = "test.main.A", .target_qn = "test.main.B", .type = "CALLS", .properties_json = "{}"}, + {.source_qn = "test.main.A", .target_qn = "test.main.C", .type = "CALLS", .properties_json = "{}"}, + {.source_qn = "test.main.B", .target_qn = "test.main.A", .type = "CALLS", .properties_json = "{}"}, + {.source_qn = "test.main.C", .target_qn = "test.main.A", .type = "CALLS", .properties_json = "{}"}, + {.source_qn = "test.main.A", .target_qn = "test.main.A", .type = "SELF", .properties_json = "{}"}, + }; + cbm_file_hash_t hash = { + .project = "test", .rel_path = "main.go", .sha256 = "old-hash", .mtime_ns = 1, .size = 10}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "old-content", + .git_oid = "old-oid", + .mtime_ns = 1, + .size = 10, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = 1, + .indexed_at = "2026-06-30T00:00:00Z"}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = 1, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = REPEATED_ENDPOINT_NODE_COUNT, + .edges = edges, + .edge_count = REPEATED_ENDPOINT_EDGE_COUNT, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &delta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_nodes(s, "test"), REPEATED_ENDPOINT_NODE_COUNT); + ASSERT_EQ(cbm_store_count_edges(s, "test"), REPEATED_ENDPOINT_EDGE_COUNT); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), + REPEATED_ENDPOINT_NODE_COUNT); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), + REPEATED_ENDPOINT_EDGE_COUNT); + + cbm_node_t bad_nodes[1] = {{.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t bad_edges[1] = {{.source_qn = "test.main.New", + .target_qn = "test.main.Missing", + .type = "CALLS", + .properties_json = "{}"}}; + cbm_file_hash_t bad_hash = { + .project = "test", .rel_path = "main.go", .sha256 = "bad-hash", .mtime_ns = 2, .size = 20}; + cbm_file_state_t bad_state = {.project = "test", + .rel_path = "main.go", + .content_hash = "bad-content", + .git_oid = "bad-oid", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = 2, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_file_delta_t bad_delta = {.project = "test", + .rel_path = "main.go", + .generation = 2, + .file_hash = &bad_hash, + .file_state = &bad_state, + .nodes = bad_nodes, + .node_count = 1, + .edges = bad_edges, + .edge_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &bad_delta), CBM_STORE_NOT_FOUND); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.A"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 0); + ASSERT_EQ(cbm_store_count_edges(s, "test"), REPEATED_ENDPOINT_EDGE_COUNT); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), + REPEATED_ENDPOINT_EDGE_COUNT); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_publish_duplicate_edge_merges_properties) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t nodes[CBM_SZ_2] = { + {.project = "test", + .label = "Function", + .name = "A", + .qualified_name = "test.main.A", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "B", + .qualified_name = "test.main.B", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_delta_edge_t edges[CBM_SZ_32]; + for (int i = 0; i < CBM_SZ_32; i++) { + edges[i] = (cbm_store_delta_edge_t){.source_qn = "test.main.A", + .target_qn = "test.main.B", + .type = "CALLS", + .properties_json = "{}"}; + } + edges[0].properties_json = "{\"first\":1}"; + edges[1].properties_json = "{\"second\":2}"; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "main.go", + .sha256 = "dup-hash", + .mtime_ns = 1, + .size = 10}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "dup-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = 1, + .indexed_at = "2026-06-30T00:00:00Z"}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = 1, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = CBM_SZ_2, + .edges = edges, + .edge_count = CBM_SZ_32, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &delta), CBM_STORE_OK); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 1); + + cbm_edge_t *stored = NULL; + int stored_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_type(s, "test", "CALLS", &stored, &stored_count), + CBM_STORE_OK); + ASSERT_EQ(stored_count, 1); + ASSERT(strstr(stored[0].properties_json, "\"first\":1") != NULL); + ASSERT(strstr(stored[0].properties_json, "\"second\":2") != NULL); + cbm_store_free_edges(stored, stored_count); + + cbm_store_close(s); + PASS(); +} + +static int store_publish_helper_file_delta_named(cbm_store_t *s, int64_t generation, + const char *name, const char *qualified_name, + const char *sha256, const char *content_hash) { + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = name, + .qualified_name = qualified_name, + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "helper.go", + .sha256 = sha256, + .mtime_ns = 1, + .size = 10}; + cbm_file_state_t state = {.project = "test", + .rel_path = "helper.go", + .content_hash = content_hash, + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = generation, + .indexed_at = "2026-06-30T00:00:00Z"}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = qualified_name, .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "helper.go", + .generation = generation, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}; + return cbm_store_publish_file_delta(s, &delta); +} + +static int store_publish_helper_file_delta(cbm_store_t *s, int64_t generation) { + return store_publish_helper_file_delta_named(s, generation, "Helper", "test.helper.Helper", + "helper-hash", "helper-content"); +} + +static int store_publish_new_helper_file_delta(cbm_store_t *s, int64_t generation) { + return store_publish_helper_file_delta_named(s, generation, "NewHelper", + "test.helper.NewHelper", "new-helper-hash", + "new-helper-content"); +} + +static int store_publish_old_main_delta(cbm_store_t *s, int64_t generation) { + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = "Old", + .qualified_name = "test.main.Old", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "main.go", + .sha256 = "old-main-hash", + .mtime_ns = 1, + .size = 10}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "old-main-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = generation, + .indexed_at = "2026-06-30T00:00:00Z"}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.main.Old", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = generation, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}; + return cbm_store_publish_file_delta(s, &delta); +} + +static int store_publish_new_main_delta_target(cbm_store_t *s, int64_t generation, + const char *target_qn) { + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t edges[1] = {{.source_qn = "test.main.New", + .target_qn = target_qn, + .type = "CALLS", + .properties_json = "{}"}}; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "main.go", + .sha256 = "new-main-hash", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "new-main-content", + .git_oid = "", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = generation, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.main.New", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_import_ref_t imports[1] = { + {.import_text = "test.helper", .local_name = "Helper", .target_qn = target_qn}}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = generation, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = 1, + .edges = edges, + .edge_count = 1, + .exports = exports, + .export_count = 1, + .imports = imports, + .import_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + return cbm_store_publish_file_delta(s, &delta); +} + +static int store_publish_new_main_delta(cbm_store_t *s, int64_t generation) { + return store_publish_new_main_delta_target(s, generation, "test.helper.Helper"); +} + +static int store_publish_new_main_to_new_helper_delta(cbm_store_t *s, int64_t generation) { + return store_publish_new_main_delta_target(s, generation, "test.helper.NewHelper"); +} + +static int store_publish_bad_main_delta(cbm_store_t *s, int64_t generation) { + cbm_node_t nodes[1] = {{.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t edges[1] = {{.source_qn = "test.main.New", + .target_qn = "test.main.Missing", + .type = "CALLS", + .properties_json = "{}"}}; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "main.go", + .sha256 = "bad-main-hash", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "bad-main-content", + .git_oid = "", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = generation, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = generation, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = 1, + .edges = edges, + .edge_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + return cbm_store_publish_file_delta(s, &delta); +} + +TEST(store_file_delta_publish_matches_fresh_final_graph) { + enum { + BASE_GENERATION = 1, + FINAL_GENERATION = 2, + EXPECTED_FINAL_NODES = 2, + EXPECTED_FINAL_EDGES = 1, + }; + cbm_store_t *delta_store = cbm_store_open_memory(); + cbm_store_t *fresh_store = cbm_store_open_memory(); + ASSERT_NOT_NULL(delta_store); + ASSERT_NOT_NULL(fresh_store); + ASSERT_EQ(cbm_store_upsert_project(delta_store, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(fresh_store, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(delta_store, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(delta_store, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(delta_store, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(delta_store, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(delta_store, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FINAL_GENERATION); + ASSERT_EQ(store_publish_new_main_delta(delta_store, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(delta_store, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(fresh_store, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(fresh_store, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(fresh_store, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(fresh_store, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FINAL_GENERATION); + ASSERT_EQ(store_publish_new_main_delta(fresh_store, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(fresh_store, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_count_nodes(delta_store, "test"), cbm_store_count_nodes(fresh_store, "test")); + ASSERT_EQ(cbm_store_count_edges(delta_store, "test"), cbm_store_count_edges(fresh_store, "test")); + ASSERT_EQ(cbm_store_count_nodes(delta_store, "test"), EXPECTED_FINAL_NODES); + ASSERT_EQ(cbm_store_count_edges(delta_store, "test"), EXPECTED_FINAL_EDGES); + ASSERT_EQ(store_node_qn_exists(delta_store, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(fresh_store, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(delta_store, "test", "test.main.New"), 1); + ASSERT_EQ(store_node_qn_exists(fresh_store, "test", "test.main.New"), 1); + ASSERT_EQ(store_node_qn_exists(delta_store, "test", "test.helper.Helper"), 1); + ASSERT_EQ(store_node_qn_exists(fresh_store, "test", "test.helper.Helper"), 1); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(delta_store, "test", "main.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "new-main-content"); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + ASSERT_EQ(cbm_store_get_file_state(fresh_store, "test", "main.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "new-main-content"); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + + char **items = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(delta_store, "test", "main.go", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "test.main.New"); + store_free_string_array(items, count); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(delta_store, "test", + "test.helper.Helper", &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "main.go"); + store_free_string_array(items, count); + + char *tmp = th_mktempdir("cbm_delta_graph_diff"); + ASSERT_NOT_NULL(tmp); + const char *delta_db = TH_PATH(tmp, "delta.db"); + const char *fresh_db = TH_PATH(tmp, "fresh.db"); + ASSERT_EQ(cbm_store_dump_to_file(delta_store, delta_db), CBM_STORE_OK); + ASSERT_EQ(cbm_store_dump_to_file(fresh_store, fresh_db), CBM_STORE_OK); + char diff_err[CBM_SZ_8K] = {0}; + ASSERT_EQ(cbm_test_compare_canonical_graphs(delta_db, fresh_db, "test", diff_err, + sizeof(diff_err)), + 0); + + cbm_store_close(delta_store); + cbm_store_close(fresh_store); + th_cleanup(tmp); + PASS(); +} + +TEST(store_file_delta_graph_noop_refreshes_metadata_only) { + enum { + BASE_GENERATION = 1, + FINAL_GENERATION = 2, + }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, BASE_GENERATION), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", BASE_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_delete_symbol_exports_by_file(s, "test", "helper.go"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FINAL_GENERATION); + + cbm_node_t same_nodes[1] = {{.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.helper.Helper", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_file_hash_t hash = {.project = "test", + .rel_path = "helper.go", + .sha256 = "helper-hash-v2", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t state = {.project = "test", + .rel_path = "helper.go", + .content_hash = "helper-content-v2", + .git_oid = "", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = FINAL_GENERATION, + .indexed_at = "2026-06-30T00:02:00Z"}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.helper.Helper", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t same_delta = {.project = "test", + .rel_path = "helper.go", + .generation = FINAL_GENERATION, + .file_hash = &hash, + .file_state = &state, + .nodes = same_nodes, + .node_count = 1, + .exports = exports, + .export_count = 1}; + const cbm_store_file_delta_t *same_deltas[] = {&same_delta}; + bool graph_equal = false; + ASSERT_EQ(cbm_store_file_delta_batch_graph_equal(s, same_deltas, 1, &graph_equal), + CBM_STORE_OK); + ASSERT_TRUE(graph_equal); + ASSERT_EQ(cbm_store_refresh_file_delta_metadata_batch_complete(s, same_deltas, 1), + CBM_STORE_OK); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 0); + char **restored_exports = NULL; + int restored_export_count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "helper.go", &restored_exports, + &restored_export_count), + CBM_STORE_OK); + ASSERT_EQ(restored_export_count, 1); + store_free_string_array(restored_exports, restored_export_count); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "helper.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "helper-content-v2"); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + + cbm_node_t changed_nodes[1] = {{.project = "test", + .label = "Function", + .name = "Renamed", + .qualified_name = "test.helper.Renamed", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_store_symbol_export_t changed_exports[1] = { + {.qualified_name = "test.helper.Renamed", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t changed_delta = same_delta; + changed_delta.nodes = changed_nodes; + changed_delta.exports = changed_exports; + const cbm_store_file_delta_t *changed_deltas[] = {&changed_delta}; + graph_equal = true; + ASSERT_EQ(cbm_store_file_delta_batch_graph_equal(s, changed_deltas, 1, &graph_equal), + CBM_STORE_OK); + ASSERT_FALSE(graph_equal); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_preserves_owned_graph_detects_additive_subset) { + enum { BASE_GENERATION = 1, DELTA_GENERATION = 2 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(store_publish_helper_file_delta(s, BASE_GENERATION), CBM_STORE_OK); + + cbm_node_t additive_nodes[2] = { + {.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.helper.Helper", + .file_path = "helper.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "Added", + .qualified_name = "test.helper.Added", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_store_file_delta_t additive_delta = {.project = "test", + .rel_path = "helper.go", + .generation = DELTA_GENERATION, + .nodes = additive_nodes, + .node_count = 2}; + const cbm_store_file_delta_t *additive_deltas[] = {&additive_delta}; + bool preserves = false; + ASSERT_EQ(cbm_store_file_delta_batch_preserves_owned_graph(s, additive_deltas, 1, + &preserves), + CBM_STORE_OK); + ASSERT_TRUE(preserves); + + cbm_node_t replacement_nodes[1] = {{.project = "test", + .label = "Function", + .name = "Added", + .qualified_name = "test.helper.Added", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_store_file_delta_t replacement_delta = additive_delta; + replacement_delta.nodes = replacement_nodes; + replacement_delta.node_count = 1; + const cbm_store_file_delta_t *replacement_deltas[] = {&replacement_delta}; + preserves = true; + ASSERT_EQ(cbm_store_file_delta_batch_preserves_owned_graph(s, replacement_deltas, 1, + &preserves), + CBM_STORE_OK); + ASSERT_FALSE(preserves); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_publish_failure_finishes_generation_failed) { + enum { BASE_GENERATION = 1, FAILED_GENERATION = 2 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FAILED_GENERATION); + ASSERT_EQ(store_publish_bad_main_delta(s, generation), CBM_STORE_NOT_FOUND); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_FAILED), + CBM_STORE_OK); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 0); + ASSERT_EQ(store_count_index_generation(s, "test", FAILED_GENERATION, + CBM_STORE_INDEX_STATUS_FAILED, "", "", + STORE_TEST_COMPLETED_SET), + 1); + ASSERT_EQ(store_count_index_generation(s, "test", FAILED_GENERATION, + CBM_STORE_INDEX_STATUS_RESERVED, "", "", + STORE_TEST_COMPLETED_NULL), + 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_publish_multifile_generation) { + enum { + BASE_GENERATION = 1, + FINAL_GENERATION = 2, + EXPECTED_FINAL_NODES = 2, + EXPECTED_FINAL_EDGES = 1, + }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FINAL_GENERATION); + ASSERT_EQ(store_publish_new_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_new_main_to_new_helper_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); - /* Verify all IDs are non-zero */ - for (int i = 0; i < 150; i++) { - ASSERT_GT(ids[i], 0); - } + ASSERT_EQ(cbm_store_count_nodes(s, "test"), EXPECTED_FINAL_NODES); + ASSERT_EQ(cbm_store_count_edges(s, "test"), EXPECTED_FINAL_EDGES); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.NewHelper"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 1); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "helper.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "new-helper-content"); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "new-main-content"); + ASSERT_EQ(got.generation, FINAL_GENERATION); + cbm_store_file_state_free_fields(&got); + + char **items = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.helper.NewHelper", + &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "main.go"); + store_free_string_array(items, count); + ASSERT_EQ(store_count_index_generation(s, "test", FINAL_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "", "", + STORE_TEST_COMPLETED_SET), + 1); + ASSERT_EQ(store_count_stale_graph_derived_views(s, "test", FINAL_GENERATION), + store_graph_derived_view_count()); - /* Verify count */ - int cnt = cbm_store_count_nodes(s, "test"); - ASSERT_EQ(cnt, 150); + cbm_store_close(s); + PASS(); +} - /* Re-upsert should not duplicate */ - int64_t ids2[150]; - rc = cbm_store_upsert_node_batch(s, nodes, 150, ids2); - ASSERT_EQ(rc, CBM_STORE_OK); - cnt = cbm_store_count_nodes(s, "test"); - ASSERT_EQ(cnt, 150); +TEST(store_file_delta_batch_publish_rolls_back_all_files) { + enum { BASE_GENERATION = 1, FAILED_GENERATION = 2, BATCH_DELTA_COUNT = 2 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); - /* IDs should be the same */ - for (int i = 0; i < 150; i++) { - ASSERT_EQ(ids[i], ids2[i]); - } + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(store_publish_old_main_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, FAILED_GENERATION); + + cbm_node_t helper_nodes[1] = {{.project = "test", + .label = "Function", + .name = "NewHelper", + .qualified_name = "test.helper.NewHelper", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_file_hash_t helper_hash = {.project = "test", + .rel_path = "helper.go", + .sha256 = "new-helper-hash", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t helper_state = {.project = "test", + .rel_path = "helper.go", + .content_hash = "new-helper-content", + .git_oid = "", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = generation, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_symbol_export_t helper_exports[1] = { + {.qualified_name = "test.helper.NewHelper", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t helper_delta = {.project = "test", + .rel_path = "helper.go", + .generation = generation, + .file_hash = &helper_hash, + .file_state = &helper_state, + .nodes = helper_nodes, + .node_count = 1, + .exports = helper_exports, + .export_count = 1}; + + cbm_node_t main_nodes[1] = {{.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_store_delta_edge_t bad_edges[1] = {{.source_qn = "test.main.New", + .target_qn = "test.main.Missing", + .type = "CALLS", + .properties_json = "{}"}}; + cbm_file_hash_t main_hash = {.project = "test", + .rel_path = "main.go", + .sha256 = "bad-main-hash", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t main_state = {.project = "test", + .rel_path = "main.go", + .content_hash = "bad-main-content", + .git_oid = "", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = generation, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_file_delta_t bad_main_delta = {.project = "test", + .rel_path = "main.go", + .generation = generation, + .file_hash = &main_hash, + .file_state = &main_state, + .nodes = main_nodes, + .node_count = 1, + .edges = bad_edges, + .edge_count = 1}; + const cbm_store_file_delta_t *deltas[BATCH_DELTA_COUNT] = {&helper_delta, &bad_main_delta}; + ASSERT_EQ(cbm_store_publish_file_delta_batch(s, deltas, BATCH_DELTA_COUNT), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_FAILED), + CBM_STORE_OK); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.NewHelper"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 0); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 0); + + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "helper.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "helper-content"); + ASSERT_EQ(got.generation, BASE_GENERATION); + cbm_store_file_state_free_fields(&got); + ASSERT_EQ(store_count_index_generation(s, "test", FAILED_GENERATION, + CBM_STORE_INDEX_STATUS_FAILED, "", "", + STORE_TEST_COMPLETED_SET), + 1); cbm_store_close(s); PASS(); } -TEST(store_node_batch_empty) { +TEST(store_file_delta_batch_complete_marks_graph_views_stale) { + enum { BATCH_GENERATION = 1, BATCH_DELTA_COUNT = 1 }; cbm_store_t *s = cbm_store_open_memory(); - int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL); - ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BATCH_GENERATION); + + cbm_node_t helper_nodes[1] = {{.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.helper.Helper", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_file_hash_t helper_hash = {.project = "test", + .rel_path = "helper.go", + .sha256 = "helper-hash", + .mtime_ns = 1, + .size = 10}; + cbm_file_state_t helper_state = {.project = "test", + .rel_path = "helper.go", + .content_hash = "helper-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "c", + .pass_fingerprint = "pass-a", + .generation = BATCH_GENERATION, + .indexed_at = "2026-06-30T00:00:00Z"}; + cbm_store_symbol_export_t helper_exports[1] = { + {.qualified_name = "test.helper.Helper", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t helper_delta = {.project = "test", + .rel_path = "helper.go", + .generation = BATCH_GENERATION, + .file_hash = &helper_hash, + .file_state = &helper_state, + .nodes = helper_nodes, + .node_count = 1, + .exports = helper_exports, + .export_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + const cbm_store_file_delta_t *deltas[BATCH_DELTA_COUNT] = {&helper_delta}; + ASSERT_EQ(cbm_store_publish_file_delta_batch_complete(s, deltas, BATCH_DELTA_COUNT), + CBM_STORE_OK); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(store_count_index_generation(s, "test", BATCH_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "", "", + STORE_TEST_COMPLETED_SET), + 1); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODES_FTS, + BATCH_GENERATION, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 1); + ASSERT_EQ(store_count_stale_graph_derived_views(s, "test", BATCH_GENERATION), + store_graph_derived_view_count()); + cbm_store_close(s); PASS(); } -/* ── Cascade delete ─────────────────────────────────────────────── */ - -TEST(store_cascade_delete) { +TEST(store_file_delta_batch_complete_rolls_back_when_generation_missing) { + enum { BASE_GENERATION = 1, MISSING_GENERATION = 2, BATCH_DELTA_COUNT = 1 }; cbm_store_t *s = cbm_store_open_memory(); - cbm_store_upsert_project(s, "test", "/tmp/test"); - - /* Create nodes and an edge */ - cbm_node_t n1 = { - .project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"}; - cbm_node_t n2 = { - .project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"}; - int64_t id1 = cbm_store_upsert_node(s, &n1); - int64_t id2 = cbm_store_upsert_node(s, &n2); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); - cbm_edge_t e = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"}; - cbm_store_insert_edge(s, &e); + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, generation), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", generation, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); - /* Delete project — should cascade */ - cbm_store_delete_project(s, "test"); + cbm_node_t helper_nodes[1] = {{.project = "test", + .label = "Function", + .name = "NewHelper", + .qualified_name = "test.helper.NewHelper", + .file_path = "helper.go", + .properties_json = "{}"}}; + cbm_file_hash_t helper_hash = {.project = "test", + .rel_path = "helper.go", + .sha256 = "new-helper-hash", + .mtime_ns = 2, + .size = 20}; + cbm_file_state_t helper_state = {.project = "test", + .rel_path = "helper.go", + .content_hash = "new-helper-content", + .git_oid = "", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = MISSING_GENERATION, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_symbol_export_t helper_exports[1] = { + {.qualified_name = "test.helper.NewHelper", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_file_delta_t helper_delta = {.project = "test", + .rel_path = "helper.go", + .generation = MISSING_GENERATION, + .file_hash = &helper_hash, + .file_state = &helper_state, + .nodes = helper_nodes, + .node_count = 1, + .exports = helper_exports, + .export_count = 1}; + const cbm_store_file_delta_t *deltas[BATCH_DELTA_COUNT] = {&helper_delta}; + ASSERT_EQ(cbm_store_publish_file_delta_batch_complete(s, deltas, BATCH_DELTA_COUNT), + CBM_STORE_NOT_FOUND); - int ncnt = cbm_store_count_nodes(s, "test"); - int ecnt = cbm_store_count_edges(s, "test"); - ASSERT_EQ(ncnt, 0); - ASSERT_EQ(ecnt, 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.NewHelper"), 0); + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "helper.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "helper-content"); + ASSERT_EQ(got.generation, BASE_GENERATION); + cbm_store_file_state_free_fields(&got); + ASSERT_EQ(store_count_index_generation(s, "test", MISSING_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "", "", + STORE_TEST_COMPLETED_SET), + 0); cbm_store_close(s); PASS(); } -/* ── File hashes ────────────────────────────────────────────────── */ - -TEST(store_file_hash_crud) { +TEST(store_file_delta_publish_commits_graph_and_metadata) { cbm_store_t *s = cbm_store_open_memory(); - cbm_store_upsert_project(s, "test", "/tmp/test"); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); - /* Upsert */ - int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512); - ASSERT_EQ(rc, CBM_STORE_OK); + cbm_node_t old_nodes[1] = {{.project = "test", + .label = "Function", + .name = "Old", + .qualified_name = "test.main.Old", + .file_path = "main.go", + .properties_json = "{}"}}; + cbm_file_hash_t old_hash = { + .project = "test", .rel_path = "main.go", .sha256 = "old-hash", .mtime_ns = 1, .size = 10}; + cbm_store_file_delta_t old_delta = {.project = "test", + .rel_path = "main.go", + .generation = 1, + .file_hash = &old_hash, + .nodes = old_nodes, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &old_delta), CBM_STORE_OK); + + cbm_node_t nodes[2] = { + {.project = "test", + .label = "Function", + .name = "New", + .qualified_name = "test.main.New", + .file_path = "main.go", + .properties_json = "{}"}, + {.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.main.Helper", + .file_path = "main.go", + .properties_json = "{}"}, + }; + cbm_store_delta_edge_t edges[1] = {{.source_qn = "test.main.New", + .target_qn = "test.main.Helper", + .type = "CALLS", + .properties_json = "{}"}}; + cbm_file_hash_t hash = { + .project = "test", .rel_path = "main.go", .sha256 = "new-hash", .mtime_ns = 2, .size = 20}; + cbm_file_state_t state = {.project = "test", + .rel_path = "main.go", + .content_hash = "new-content", + .git_oid = "new-oid", + .mtime_ns = 2, + .size = 20, + .language = "c", + .pass_fingerprint = "pass-b", + .generation = 2, + .indexed_at = "2026-06-30T00:01:00Z"}; + cbm_store_symbol_export_t exports[1] = { + {.qualified_name = "test.main.New", .node_id = CBM_STORE_NO_NODE_ID}}; + cbm_store_import_ref_t imports[1] = { + {.import_text = "test.main", .local_name = "New", .target_qn = "test.main.New"}}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = 2, + .file_hash = &hash, + .file_state = &state, + .nodes = nodes, + .node_count = 2, + .edges = edges, + .edge_count = 1, + .exports = exports, + .export_count = 1, + .imports = imports, + .import_count = 1, + .derived_view_name = CBM_STORE_DERIVED_VIEW_NODES_FTS, + .derived_status = CBM_STORE_DERIVED_STATUS_COMPLETE}; + ASSERT_EQ(cbm_store_publish_file_delta(s, &delta), CBM_STORE_OK); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Old"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.Helper"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 2); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 1); - /* Get */ cbm_file_hash_t *hashes = NULL; int count = 0; - rc = cbm_store_get_file_hashes(s, "test", &hashes, &count); - ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_file_hashes(s, "test", &hashes, &count), CBM_STORE_OK); ASSERT_EQ(count, 1); - ASSERT_STR_EQ(hashes[0].rel_path, "main.go"); - ASSERT_STR_EQ(hashes[0].sha256, "abc123"); - ASSERT_EQ(hashes[0].mtime_ns, 1000000); - ASSERT_EQ(hashes[0].size, 512); + ASSERT_STR_EQ(hashes[0].sha256, "new-hash"); cbm_store_free_file_hashes(hashes, count); - /* Update */ - rc = cbm_store_upsert_file_hash(s, "test", "main.go", "def456", 2000000, 1024); - ASSERT_EQ(rc, CBM_STORE_OK); - rc = cbm_store_get_file_hashes(s, "test", &hashes, &count); + cbm_file_state_t got = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "main.go", &got), CBM_STORE_OK); + ASSERT_STR_EQ(got.content_hash, "new-content"); + ASSERT_EQ(got.generation, 2); + cbm_store_file_state_free_fields(&got); + + char **items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "main.go", &items, &count), + CBM_STORE_OK); ASSERT_EQ(count, 1); - ASSERT_STR_EQ(hashes[0].sha256, "def456"); - ASSERT_EQ(hashes[0].mtime_ns, 2000000); - cbm_store_free_file_hashes(hashes, count); + ASSERT_STR_EQ(items[0], "test.main.New"); + store_free_string_array(items, count); - /* Delete single */ - rc = cbm_store_delete_file_hash(s, "test", "main.go"); - ASSERT_EQ(rc, CBM_STORE_OK); - rc = cbm_store_get_file_hashes(s, "test", &hashes, &count); - ASSERT_EQ(count, 0); - cbm_store_free_file_hashes(hashes, count); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.main.New", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "main.go"); + store_free_string_array(items, count); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODES_FTS, 2, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 1); + ASSERT_EQ(store_count_stale_graph_derived_views(s, "test", 2), + store_graph_derived_view_count()); cbm_store_close(s); PASS(); } -TEST(store_file_hash_upsert_rejects_null_required_fields) { - /* Pins the API contract that `cbm_store_upsert_file_hash` returns - * CBM_STORE_ERR (not silent OK) when a NOT NULL column would receive - * SQL NULL. This is the failure mode that - * `pipeline_incremental.c:persist_hashes` checks for and logs as - * `incremental.persist_hash_failed`. If this contract ever changes - * (e.g. the schema relaxes NOT NULL on rel_path or sha256), the - * downstream warning becomes silent and the orphaned-node bug class - * can re-emerge. Track that change here, not just in the consumer. */ +TEST(store_file_delta_delete_cleans_graph_and_metadata) { + enum { + BASE_GENERATION = 1, + DELETE_GENERATION = 2, + }; cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); - cbm_store_upsert_project(s, "test", "/tmp/test"); - - /* Sanity: a fully-valid upsert returns OK. */ - int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512); - ASSERT_EQ(rc, CBM_STORE_OK); - - /* NULL sha256 violates NOT NULL on file_hashes.sha256 → must return ERR. */ - rc = cbm_store_upsert_file_hash(s, "test", "other.go", NULL, 2000000, 1024); - ASSERT_EQ(rc, CBM_STORE_ERR); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(store_publish_helper_file_delta(s, BASE_GENERATION), CBM_STORE_OK); + ASSERT_EQ(store_publish_new_main_delta(s, BASE_GENERATION), CBM_STORE_OK); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 1); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 1); + + ASSERT_EQ(cbm_store_delete_file_delta(s, "test", "helper.go", DELETE_GENERATION, + CBM_STORE_DERIVED_VIEW_NODES_FTS), + CBM_STORE_OK); - /* NULL rel_path violates NOT NULL on file_hashes.rel_path → must return ERR. */ - rc = cbm_store_upsert_file_hash(s, "test", NULL, "deadbeef", 3000000, 2048); - ASSERT_EQ(rc, CBM_STORE_ERR); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 0); + ASSERT_EQ(store_node_qn_exists(s, "test", "test.main.New"), 1); + ASSERT_EQ(cbm_store_count_edges(s, "test"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "helper.go"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "helper.go"), 0); + ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "main.go"), 1); + ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "main.go"), 0); - /* NULL project violates NOT NULL on file_hashes.project → must return ERR. */ - rc = cbm_store_upsert_file_hash(s, NULL, "third.go", "cafebabe", 4000000, 4096); - ASSERT_EQ(rc, CBM_STORE_ERR); + cbm_file_state_t deleted_state = {0}; + ASSERT_EQ(cbm_store_get_file_state(s, "test", "helper.go", &deleted_state), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODES_FTS, + DELETE_GENERATION, CBM_STORE_DERIVED_STATUS_STALE), + 1); + ASSERT_EQ(store_count_stale_graph_derived_views(s, "test", DELETE_GENERATION), + store_graph_derived_view_count()); - /* The valid row from earlier must still be present — partial-failure - * policy: a single bad upsert does not corrupt or remove other rows. */ cbm_file_hash_t *hashes = NULL; - int count = 0; - cbm_store_get_file_hashes(s, "test", &hashes, &count); - ASSERT_EQ(count, 1); + int hash_count = 0; + ASSERT_EQ(cbm_store_get_file_hashes(s, "test", &hashes, &hash_count), CBM_STORE_OK); + ASSERT_EQ(hash_count, 1); ASSERT_STR_EQ(hashes[0].rel_path, "main.go"); - cbm_store_free_file_hashes(hashes, count); + cbm_store_free_file_hashes(hashes, hash_count); + + cbm_store_close(s); + PASS(); +} + +TEST(store_file_delta_delete_complete_finishes_generation) { + enum { + BASE_GENERATION = 1, + DELETE_GENERATION = 2, + }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + int64_t generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, BASE_GENERATION); + ASSERT_EQ(store_publish_helper_file_delta(s, BASE_GENERATION), CBM_STORE_OK); + ASSERT_EQ(cbm_store_finish_index_generation(s, "test", BASE_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE), + CBM_STORE_OK); + + generation = 0; + ASSERT_EQ(cbm_store_reserve_index_generation(s, "test", NULL, NULL, &generation), + CBM_STORE_OK); + ASSERT_EQ(generation, DELETE_GENERATION); + ASSERT_EQ(cbm_store_delete_file_delta_complete(s, "test", "helper.go", generation, + CBM_STORE_DERIVED_VIEW_NODES_FTS), + CBM_STORE_OK); + + ASSERT_EQ(store_node_qn_exists(s, "test", "test.helper.Helper"), 0); + ASSERT_EQ(store_count_index_generation(s, "test", DELETE_GENERATION, + CBM_STORE_INDEX_STATUS_COMPLETE, "", "", + STORE_TEST_COMPLETED_SET), + 1); + + cbm_store_close(s); + PASS(); +} + +TEST(store_derived_view_state_public_api) { + enum { + STALE_GENERATION = 5, + COMPLETE_GENERATION = 6, + }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + const char *views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_VIEW_NODE_DEGREE, + CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES}; + ASSERT_EQ(cbm_store_mark_derived_views_stale(s, "test", STALE_GENERATION, views, + (int)(sizeof(views) / sizeof(views[0]))), + CBM_STORE_OK); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + STALE_GENERATION, CBM_STORE_DERIVED_STATUS_STALE), + 1); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODE_DEGREE, + STALE_GENERATION, CBM_STORE_DERIVED_STATUS_STALE), + 1); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_SEMANTIC_EDGES, + STALE_GENERATION, CBM_STORE_DERIVED_STATUS_STALE), + 1); + cbm_derived_view_state_t got = {0}; + ASSERT_EQ(cbm_store_get_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_NODE_DEGREE, + &got), + CBM_STORE_OK); + ASSERT_TRUE(cbm_store_derived_view_is_stale(s, "test", + CBM_STORE_DERIVED_VIEW_NODE_DEGREE)); + ASSERT_STR_EQ(got.project, "test"); + ASSERT_STR_EQ(got.view_name, CBM_STORE_DERIVED_VIEW_NODE_DEGREE); + ASSERT_EQ(got.source_generation, STALE_GENERATION); + ASSERT_STR_EQ(got.status, CBM_STORE_DERIVED_STATUS_STALE); + ASSERT_NOT_NULL(got.computed_at); + cbm_store_derived_view_state_free_fields(&got); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + COMPLETE_GENERATION, + CBM_STORE_DERIVED_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + STALE_GENERATION, CBM_STORE_DERIVED_STATUS_STALE), + 0); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + COMPLETE_GENERATION, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 1); + ASSERT_EQ(cbm_store_get_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + &got), + CBM_STORE_OK); + ASSERT_EQ(got.source_generation, COMPLETE_GENERATION); + ASSERT_STR_EQ(got.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&got); + ASSERT_FALSE(cbm_store_derived_view_is_stale(s, "test", + CBM_STORE_DERIVED_VIEW_PAGERANK)); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_COMPLETE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + &got), + CBM_STORE_OK); + ASSERT_EQ(got.source_generation, CBM_STORE_DERIVED_GENERATION_UNKNOWN); + ASSERT_STR_EQ(got.status, CBM_STORE_DERIVED_STATUS_COMPLETE); + cbm_store_derived_view_state_free_fields(&got); + + const char *complete_views[] = {CBM_STORE_DERIVED_VIEW_ROUTES, + CBM_STORE_DERIVED_VIEW_ARCHITECTURE}; + ASSERT_EQ(cbm_store_mark_derived_views_complete( + s, "test", COMPLETE_GENERATION, complete_views, + (int)(sizeof(complete_views) / sizeof(complete_views[0]))), + CBM_STORE_OK); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_ROUTES, + COMPLETE_GENERATION, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 1); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_ARCHITECTURE, + COMPLETE_GENERATION, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 1); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_LINKRANK, + COMPLETE_GENERATION, + STORE_TEST_INVALID_DERIVED_STATUS), + CBM_STORE_ERR); + ASSERT_EQ(store_count_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_LINKRANK, + COMPLETE_GENERATION, + CBM_STORE_DERIVED_STATUS_COMPLETE), + 0); + ASSERT_EQ(cbm_store_get_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_LINKRANK, + &got), + CBM_STORE_NOT_FOUND); + ASSERT_EQ(cbm_store_mark_derived_views_stale(s, "test", COMPLETE_GENERATION, NULL, 0), + CBM_STORE_OK); cbm_store_close(s); PASS(); @@ -786,16 +5733,18 @@ TEST(store_node_degree) { cbm_store_insert_edge(s, &e4); int inA, outA, inB, outB, inC, outC; + /* DF-1: cbm_store_node_degree returns total degree (all edge types). + * A: 0 in, 3 out (2 CALLS + 1 USAGE). B: 1 in, 1 out. C: 3 in (2 CALLS + 1 USAGE), 0 out. */ cbm_store_node_degree(s, idA, &inA, &outA); ASSERT_EQ(inA, 0); - ASSERT_EQ(outA, 2); + ASSERT_EQ(outA, 3); cbm_store_node_degree(s, idB, &inB, &outB); ASSERT_EQ(inB, 1); ASSERT_EQ(outB, 1); cbm_store_node_degree(s, idC, &inC, &outC); - ASSERT_EQ(inC, 2); + ASSERT_EQ(inC, 3); ASSERT_EQ(outC, 0); cbm_store_close(s); @@ -1027,6 +5976,16 @@ TEST(store_find_node_ids_by_qns) { ASSERT_EQ(ids[1], id2); ASSERT_EQ(ids[2], 0); /* missing → 0 */ + const char *mixed_qns[] = {"test.A", NULL, "test.B", "test.A", "other.C"}; + int64_t mixed_ids[5]; + int mixed_found = cbm_store_find_node_ids_by_qns(s, "test", mixed_qns, 5, mixed_ids); + ASSERT_EQ(mixed_found, 3); + ASSERT_EQ(mixed_ids[0], id1); + ASSERT_EQ(mixed_ids[1], 0); + ASSERT_EQ(mixed_ids[2], id2); + ASSERT_EQ(mixed_ids[3], id1); + ASSERT_EQ(mixed_ids[4], 0); + /* Empty batch */ int found2 = cbm_store_find_node_ids_by_qns(s, "test", NULL, 0, ids); ASSERT_EQ(found2, 0); @@ -1035,6 +5994,147 @@ TEST(store_find_node_ids_by_qns) { PASS(); } +TEST(store_find_nodes_by_qns_returns_full_rows_in_input_order) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cbm_store_upsert_project(s, "test", "/tmp/test") == CBM_STORE_OK); + ASSERT_TRUE(cbm_store_upsert_project(s, "other", "/tmp/other") == CBM_STORE_OK); + + cbm_node_t na = {.project = "test", + .label = "Function", + .name = "A", + .qualified_name = "test.A", + .file_path = "a.c", + .start_line = 7, + .end_line = 9, + .properties_json = "{\"return_type\":\"int\"}"}; + cbm_node_t nb = {.project = "test", + .label = "Class", + .name = "B", + .qualified_name = "test.B", + .file_path = "b.c", + .properties_json = "{\"base_classes\":[\"Base\"]}"}; + cbm_node_t other = {.project = "other", + .label = "Function", + .name = "A", + .qualified_name = "test.A", + .file_path = "other.c"}; + ASSERT_TRUE(cbm_store_upsert_node(s, &na) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &nb) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &other) > 0); + + const char *qns[] = {"test.B", "missing.Q", NULL, "test.A", "test.B"}; + cbm_node_t *nodes = NULL; + int count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_qns(s, "test", qns, (int)(sizeof(qns) / sizeof(qns[0])), + &nodes, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(nodes[0].qualified_name, "test.B"); + ASSERT_STR_EQ(nodes[0].project, "test"); + ASSERT_STR_EQ(nodes[0].properties_json, "{\"base_classes\":[\"Base\"]}"); + ASSERT_STR_EQ(nodes[1].qualified_name, "test.A"); + ASSERT_STR_EQ(nodes[1].file_path, "a.c"); + ASSERT_EQ(nodes[1].start_line, 7); + ASSERT_STR_EQ(nodes[1].properties_json, "{\"return_type\":\"int\"}"); + ASSERT_STR_EQ(nodes[2].qualified_name, "test.B"); + cbm_store_free_nodes(nodes, count); + + ASSERT_EQ(cbm_store_find_nodes_by_qns(s, "test", NULL, 1, &nodes, &count), CBM_STORE_ERR); + ASSERT_EQ(cbm_store_find_nodes_by_qns(s, "test", NULL, 0, &nodes, &count), CBM_STORE_OK); + ASSERT_EQ(count, 0); + + cbm_store_close(s); + PASS(); +} + +TEST(store_list_symbol_scope_qns_by_qns_expands_exact_and_members) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cbm_store_upsert_project(s, "test", "/tmp/test") == CBM_STORE_OK); + ASSERT_TRUE(cbm_store_upsert_project(s, "other", "/tmp/other") == CBM_STORE_OK); + + cbm_node_t cls = {.project = "test", + .label = "Class", + .name = "Logger", + .qualified_name = "test.provider.Logger"}; + cbm_node_t debug = {.project = "test", + .label = "Method", + .name = "debug", + .qualified_name = "test.provider.Logger.debug"}; + cbm_node_t log = {.project = "test", + .label = "Method", + .name = "log", + .qualified_name = "test.provider.Logger.log"}; + cbm_node_t sibling = {.project = "test", + .label = "Method", + .name = "log", + .qualified_name = "test.provider.LoggerExtra.log"}; + cbm_node_t unrelated = {.project = "test", + .label = "Method", + .name = "log", + .qualified_name = "test.provider.OtherLogger.log"}; + cbm_node_t other_project = {.project = "other", + .label = "Method", + .name = "trace", + .qualified_name = "test.provider.Logger.trace"}; + ASSERT_TRUE(cbm_store_upsert_node(s, &cls) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &debug) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &log) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &sibling) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &unrelated) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &other_project) > 0); + + const char *scopes[] = {"test.provider.Logger", "missing.Q", NULL, "test.provider.Logger"}; + char **qns = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + s, "test", scopes, (int)(sizeof(scopes) / sizeof(scopes[0])), CBM_SZ_16, &qns, + &count, &truncated), + CBM_STORE_OK); + ASSERT_FALSE(truncated); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(qns[0], "test.provider.Logger"); + ASSERT_STR_EQ(qns[1], "test.provider.Logger.debug"); + ASSERT_STR_EQ(qns[2], "test.provider.Logger.log"); + for (int i = 0; i < count; i++) { + free(qns[i]); + } + free(qns); + + qns = NULL; + count = 0; + truncated = false; + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + s, "test", scopes, (int)(sizeof(scopes) / sizeof(scopes[0])), PAIR_LEN, &qns, + &count, &truncated), + CBM_STORE_OK); + ASSERT_TRUE(truncated); + ASSERT_EQ(count, PAIR_LEN); + ASSERT_STR_EQ(qns[0], "test.provider.Logger"); + ASSERT_STR_EQ(qns[1], "test.provider.Logger.debug"); + for (int i = 0; i < count; i++) { + free(qns[i]); + } + free(qns); + + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + s, "test", scopes, (int)(sizeof(scopes) / sizeof(scopes[0])), 0, &qns, &count, + NULL), + CBM_STORE_ERR); + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns(s, "test", NULL, 1, CBM_SZ_16, &qns, + &count, NULL), + CBM_STORE_ERR); + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns(s, "test", NULL, 0, CBM_SZ_16, &qns, + &count, NULL), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + + cbm_store_close(s); + PASS(); +} + /* ── Integrity check tests ──────────────────────────────────────── */ TEST(store_integrity_clean) { @@ -1083,8 +6183,9 @@ TEST(store_integrity_windows_lowercase_drive_issue367) { PASS(); } -TEST(store_integrity_corrupt_too_many_rows) { - /* Simulate corruption: >5 rows in projects table */ +TEST(store_integrity_multiple_project_rows_allowed) { + /* Dependency projects are stored in the parent DB, so a valid store may + * contain more than one projects row. Row count alone is not corruption. */ cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); sqlite3 *db = cbm_store_get_db(s); @@ -1096,7 +6197,7 @@ TEST(store_integrity_corrupt_too_many_rows) { i, i); sqlite3_exec(db, sql, NULL, NULL, NULL); } - ASSERT_FALSE(cbm_store_check_integrity(s)); + ASSERT_TRUE(cbm_store_check_integrity(s)); cbm_store_close(s); PASS(); } @@ -1107,6 +6208,133 @@ TEST(store_integrity_null_check) { PASS(); } +TEST(store_project_graph_stats_are_exact_and_generation_invalidated) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "stats", "/tmp/stats"), CBM_STORE_OK); + + cbm_node_t first = {.project = "stats", + .label = "Function", + .name = "first", + .qualified_name = "stats.first"}; + cbm_node_t second = {.project = "stats", + .label = "Function", + .name = "second", + .qualified_name = "stats.second"}; + int64_t first_id = cbm_store_upsert_node(s, &first); + int64_t second_id = cbm_store_upsert_node(s, &second); + ASSERT_GT(first_id, 0); + ASSERT_GT(second_id, 0); + cbm_edge_t edge = { + .project = "stats", .source_id = first_id, .target_id = second_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &edge), 0); + ASSERT_EQ(cbm_store_exec(s, + "INSERT INTO pagerank(project,node_id,rank,computed_at) VALUES" + "('stats',1,0.6,'2026-07-31T20:00:00Z')," + "('stats',2,0.4,'2026-07-31T20:00:00Z');"), + CBM_STORE_OK); + + cbm_project_graph_stats_t stats = {0}; + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 2); + ASSERT_EQ(stats.edge_count, 1); + ASSERT_EQ(stats.ranked_node_count, 2); + ASSERT_STR_EQ(stats.pagerank_computed_at, "2026-07-31T20:00:00Z"); + cbm_store_project_graph_stats_free_fields(&stats); + ASSERT_EQ(cbm_store_refresh_project_graph_stats(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 2); + ASSERT_EQ(stats.edge_count, 1); + ASSERT_EQ(stats.ranked_node_count, 2); + ASSERT_STR_EQ(stats.pagerank_computed_at, "2026-07-31T20:00:00Z"); + cbm_store_project_graph_stats_free_fields(&stats); + + cbm_node_t third = {.project = "stats", + .label = "Function", + .name = "third", + .qualified_name = "stats.third"}; + ASSERT_GT(cbm_store_upsert_node(s, &third), 0); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 3); + cbm_store_project_graph_stats_free_fields(&stats); + ASSERT_EQ(cbm_store_refresh_project_graph_stats(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 3); + cbm_store_project_graph_stats_free_fields(&stats); + + ASSERT_EQ(cbm_store_begin(s), CBM_STORE_OK); + cbm_node_t rolled_back = {.project = "stats", + .label = "Function", + .name = "rolled_back", + .qualified_name = "stats.rolled_back"}; + ASSERT_GT(cbm_store_upsert_node(s, &rolled_back), 0); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 4); + cbm_store_project_graph_stats_free_fields(&stats); + ASSERT_EQ(cbm_store_rollback(s), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 3); + cbm_store_project_graph_stats_free_fields(&stats); + + /* A read-only pre-migration database lacks only the new materialization; + * exact fallback remains automatic. Unrelated schema failures stay errors + * instead of being misreported as valid zero counts. */ + ASSERT_EQ(cbm_store_exec(s, "DROP TABLE project_graph_stats;"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_OK); + ASSERT_EQ(stats.node_count, 3); + ASSERT_EQ(stats.edge_count, 1); + ASSERT_EQ(stats.ranked_node_count, 2); + cbm_store_project_graph_stats_free_fields(&stats); + ASSERT_EQ(cbm_store_exec(s, "DROP TABLE nodes;"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_get_project_graph_stats(s, "stats", &stats), CBM_STORE_ERR); + + cbm_store_close(s); + PASS(); +} + +TEST(store_integrity_full_path_only_classification) { + /* The _full variant must classify a bad root_path (with an otherwise-fine + * projects table) as a path-only defect so callers can retain the DB + * (#557), while genuine corruption (too many rows) is NOT path-only. */ + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + bool path_only = true; + + /* Clean DB: passes, path_only stays false. */ + cbm_store_upsert_project(s, "clean-proj", "/tmp/clean"); + path_only = true; + ASSERT_TRUE(cbm_store_check_integrity_full(s, &path_only)); + ASSERT_FALSE(path_only); + + /* Bad root_path, single row: fails, path_only == true (retain-eligible). */ + sqlite3 *db = cbm_store_get_db(s); + sqlite3_exec(db, "DELETE FROM projects;", NULL, NULL, NULL); + sqlite3_exec(db, + "INSERT INTO projects (name, indexed_at, root_path) " + "VALUES ('bad-path-proj', '2024-01-01', '6860');", + NULL, NULL, NULL); + path_only = false; + ASSERT_FALSE(cbm_store_check_integrity_full(s, &path_only)); + ASSERT_TRUE(path_only); + + /* Many valid project rows are allowed and are not a path-only failure. */ + sqlite3_exec(db, "DELETE FROM projects;", NULL, NULL, NULL); + for (int i = 0; i < 10; i++) { + char sql[256]; + snprintf(sql, sizeof(sql), + "INSERT INTO projects (name, indexed_at, root_path) " + "VALUES ('proj-%d', '2024-01-01', '/tmp/%d');", + i, i); + sqlite3_exec(db, sql, NULL, NULL, NULL); + } + path_only = true; + ASSERT_TRUE(cbm_store_check_integrity_full(s, &path_only)); + ASSERT_FALSE(path_only); + + cbm_store_close(s); + PASS(); +} + /* ── Edge case: NULL / empty field handling ────────────────────── */ TEST(store_node_null_project) { @@ -1972,21 +7200,35 @@ SUITE(store_nodes) { RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments); RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails); RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like); + RUN_TEST(store_vector_search_without_vector_tables_is_empty_capability); + RUN_TEST(store_vector_search_ranks_every_candidate_for_all_keywords); + RUN_TEST(store_vector_search_uses_every_nonempty_keyword); + RUN_TEST(store_vector_search_allocation_failures_are_atomic); + RUN_TEST(store_vector_search_excludes_zero_magnitude_nodes); + RUN_TEST(store_vector_search_rejects_malformed_enriched_keyword_vector); RUN_TEST(store_open_memory); RUN_TEST(store_close_null); RUN_TEST(store_open_memory_twice); + RUN_TEST(store_exact_delta_metadata_schema); + RUN_TEST(store_open_path_query_does_not_create_missing_db); + RUN_TEST(store_open_path_existing_requires_existing_writable_db); RUN_TEST(store_integrity_clean); RUN_TEST(store_integrity_empty); RUN_TEST(store_integrity_corrupt_bad_path); RUN_TEST(store_integrity_windows_lowercase_drive_issue367); - RUN_TEST(store_integrity_corrupt_too_many_rows); + RUN_TEST(store_integrity_multiple_project_rows_allowed); RUN_TEST(store_integrity_null_check); + RUN_TEST(store_project_graph_stats_are_exact_and_generation_invalidated); + RUN_TEST(store_integrity_full_path_only_classification); RUN_TEST(store_project_crud); + RUN_TEST(store_project_reads_reset_cached_statements); RUN_TEST(store_project_update); RUN_TEST(store_project_delete); RUN_TEST(store_node_crud); RUN_TEST(store_node_dedup); RUN_TEST(store_node_find_by_label); + RUN_TEST(store_visit_nodes_by_label_identity_rows); + RUN_TEST(store_visit_ranked_node_refs_by_project_pattern_and_label); RUN_TEST(store_node_find_by_file); RUN_TEST(store_node_find_not_found); RUN_TEST(store_node_count_empty); @@ -1994,9 +7236,61 @@ SUITE(store_nodes) { RUN_TEST(store_node_delete_by_label); RUN_TEST(store_node_batch_upsert); RUN_TEST(store_node_batch_empty); + RUN_TEST(store_node_batch_in_transaction_bulk_rollback); RUN_TEST(store_cascade_delete); RUN_TEST(store_file_hash_crud); RUN_TEST(store_file_hash_upsert_rejects_null_required_fields); + RUN_TEST(store_file_state_crud); + RUN_TEST(store_file_state_get_resets_cached_statement); + RUN_TEST(store_index_generation_reservation_monotonic); + RUN_TEST(store_index_generation_reservation_requires_project); + RUN_TEST(store_index_generation_finish_complete); + RUN_TEST(store_latest_complete_index_generation_ignores_reserved_and_failed); + RUN_TEST(store_index_generation_finish_failed_and_invalid_status); + RUN_TEST(store_overlay_generation_reservation_status_and_counts); + RUN_TEST(store_overlay_generation_rejects_invalid_inputs); + RUN_TEST(store_claim_ready_overlay_generation_claims_oldest_once); + RUN_TEST(store_claim_ready_overlay_generation_ignores_nonready_and_validates_outputs); + RUN_TEST(store_recover_overlay_compaction_claims_releases_abandoned_claims); + RUN_TEST(store_compact_next_overlay_generation_returns_not_found_without_ready_overlay); + RUN_TEST(store_overlay_file_delta_publish_rows_and_tombstone); + RUN_TEST(store_delete_project_clears_overlay_fts); + RUN_TEST(store_overlay_file_delta_publish_rejects_invalid_delta_without_rows); + RUN_TEST(store_overlay_file_delta_batch_rolls_back_all_files); + RUN_TEST(store_overlay_file_delta_publish_rejects_failed_generation); + RUN_TEST(store_overlay_node_view_summary_counts_latest_ready_overlay); + RUN_TEST(store_overlay_additions_keep_canonical_file_rows_visible); + RUN_TEST(store_overlay_publish_prunes_superseded_file_rows_and_fts); + RUN_TEST(store_compact_overlay_generation_promotes_metadata_and_cleans_overlay); + RUN_TEST(store_compact_overlay_generation_promotes_delete_only_tombstone); + RUN_TEST(store_compact_ready_overlay_generations_respects_batch_limit); + RUN_TEST(store_find_nodes_by_file_overlay_view_returns_latest_ready_rows); + RUN_TEST(store_active_overlay_qn_view_uses_source_span_selection); + RUN_TEST(store_search_overlay_view_without_ready_overlay_matches_canonical_search); + RUN_TEST(store_search_overlay_view_matches_full_rebuild_oracle); + RUN_TEST(store_search_overlay_view_uses_active_relationship_edges); + RUN_TEST(store_search_overlay_view_dedupes_multi_owner_active_edges); + RUN_TEST(store_schema_counts_overlay_view_uses_active_nodes_and_edges); + RUN_TEST(store_owner_metadata_crud); + RUN_TEST(store_rebuild_file_delta_owners_derives_from_graph); + RUN_TEST(store_import_export_metadata_crud); + RUN_TEST(store_file_delta_affected_paths_from_exports_and_imports); + RUN_TEST(store_file_delta_affected_paths_high_fanout_dedupes); + RUN_TEST(store_file_delta_publish_rolls_back_on_failure); + RUN_TEST(store_file_delta_publish_repeated_edge_endpoints); + RUN_TEST(store_file_delta_publish_duplicate_edge_merges_properties); + RUN_TEST(store_file_delta_publish_matches_fresh_final_graph); + RUN_TEST(store_file_delta_graph_noop_refreshes_metadata_only); + RUN_TEST(store_file_delta_preserves_owned_graph_detects_additive_subset); + RUN_TEST(store_file_delta_publish_failure_finishes_generation_failed); + RUN_TEST(store_file_delta_publish_multifile_generation); + RUN_TEST(store_file_delta_batch_publish_rolls_back_all_files); + RUN_TEST(store_file_delta_batch_complete_marks_graph_views_stale); + RUN_TEST(store_file_delta_batch_complete_rolls_back_when_generation_missing); + RUN_TEST(store_file_delta_publish_commits_graph_and_metadata); + RUN_TEST(store_file_delta_delete_cleans_graph_and_metadata); + RUN_TEST(store_file_delta_delete_complete_finishes_generation); + RUN_TEST(store_derived_view_state_public_api); RUN_TEST(store_node_properties_json); RUN_TEST(store_node_null_properties); RUN_TEST(store_find_by_file_overlap); @@ -2011,6 +7305,8 @@ SUITE(store_nodes) { RUN_TEST(store_restore_from); RUN_TEST(store_pragma_settings); RUN_TEST(store_find_node_ids_by_qns); + RUN_TEST(store_find_nodes_by_qns_returns_full_rows_in_input_order); + RUN_TEST(store_list_symbol_scope_qns_by_qns_expands_exact_and_members); RUN_TEST(store_node_null_project); RUN_TEST(store_node_null_qn); RUN_TEST(store_node_empty_strings); diff --git a/tests/test_store_pragmas.c b/tests/test_store_pragmas.c index 0d093589c..382bbd039 100644 --- a/tests/test_store_pragmas.c +++ b/tests/test_store_pragmas.c @@ -198,8 +198,10 @@ TEST(corrupt_page_scan_returns_error_not_truncation) { } (void)fclose(f); - /* The scans must now fail LOUDLY (CBM_STORE_ERR), not truncate. */ - cbm_store_t *s2 = cbm_store_open_path(db_path); + /* Open through the read-only query route so schema/index maintenance does + * not reject the fixture before the row-scan contract is exercised. The + * scans themselves must fail LOUDLY (CBM_STORE_ERR), not truncate. */ + cbm_store_t *s2 = cbm_store_open_path_query(db_path); ASSERT_NOT_NULL(s2); /* The scan must CROSS the corrupt band: request every row. */ cbm_search_params_t all_params = { diff --git a/tests/test_store_search.c b/tests/test_store_search.c index 2eb189ed5..e65103d2f 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -6,6 +6,7 @@ #include "../src/foundation/compat.h" #include "test_framework.h" #include "test_helpers.h" +#include #include #include #include @@ -68,6 +69,42 @@ TEST(store_search_by_label) { PASS(); } +TEST(store_search_summary_returns_exact_facets_without_node_rows) { + int64_t ids[3]; + cbm_store_t *s = setup_search_store(ids); + + cbm_search_params_t params = { + .project = "test", + .min_degree = -1, + .max_degree = -1, + .summary_only = true, + /* Summary aggregation is independent of node-page controls. */ + .limit = 1, + .offset = 2, + }; + cbm_search_output_t out = {0}; + ASSERT_EQ(cbm_store_search(s, ¶ms, &out), CBM_STORE_OK); + ASSERT_EQ(out.total, 3); + ASSERT_EQ(out.count, 0); + ASSERT_NULL(out.results); + + ASSERT_EQ(out.label_facet_count, 2); + ASSERT_STR_EQ(out.label_facets[0].value, "Function"); + ASSERT_EQ(out.label_facets[0].count, 2); + ASSERT_STR_EQ(out.label_facets[1].value, "Class"); + ASSERT_EQ(out.label_facets[1].count, 1); + + ASSERT_EQ(out.file_facet_count, 2); + ASSERT_STR_EQ(out.file_facets[0].value, "service.go"); + ASSERT_EQ(out.file_facets[0].count, 2); + ASSERT_STR_EQ(out.file_facets[1].value, "main.go"); + ASSERT_EQ(out.file_facets[1].count, 1); + + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + /* ── Search by name pattern ─────────────────────────────────────── */ TEST(store_search_by_name_pattern) { @@ -992,6 +1029,315 @@ TEST(store_bfs_with_risk_labels) { PASS(); } +TEST(store_bfs_carries_joined_pagerank_score) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t na = { + .project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"}; + cbm_node_t nb = { + .project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"}; + int64_t idA = cbm_store_upsert_node(s, &na); + int64_t idB = cbm_store_upsert_node(s, &nb); + cbm_edge_t e = {.project = "test", .source_id = idA, .target_id = idB, .type = "CALLS"}; + cbm_store_insert_edge(s, &e); + + char rank_sql[256]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('test',%lld,0.75,'2026-06-30T00:00:00Z')", + (long long)idB); + ASSERT_EQ(cbm_store_exec(s, rank_sql), CBM_STORE_OK); + + const char *types[] = {"CALLS"}; + cbm_traverse_result_t result = {0}; + ASSERT_EQ(cbm_store_bfs(s, idA, "outbound", types, 1, 1, 10, &result), CBM_STORE_OK); + ASSERT_EQ(result.visited_count, 1); + ASSERT_EQ(result.visited[0].node.id, idB); + ASSERT_FLOAT_EQ(result.visited[0].pagerank_score, 0.75, CBM_PAGERANK_EPSILON); + + cbm_store_traverse_free(&result); + + cbm_store_trail_graph_t *trail_graph = NULL; + ASSERT_EQ(cbm_store_trail_graph_load(s, "test", "outbound", types, 1, &trail_graph), + CBM_STORE_OK); + int trail_work = 0; + bool trail_limit_hit = false; + bool trail_cancelled = false; + cbm_traverse_result_t trail_result = {0}; + ASSERT_EQ(cbm_store_trail_graph_traverse(trail_graph, idA, NULL, 1, 1, 10, NULL, NULL, + &trail_result, &trail_work, &trail_limit_hit, + &trail_cancelled), + CBM_STORE_OK); + ASSERT_EQ(trail_result.visited_count, 1); + ASSERT_FLOAT_EQ(trail_result.visited[0].pagerank_score, 0.75, CBM_PAGERANK_EPSILON); + cbm_store_traverse_free(&trail_result); + cbm_store_trail_graph_free(trail_graph); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + cbm_traverse_result_t stale_result = {0}; + ASSERT_EQ(cbm_store_bfs(s, idA, "outbound", types, 1, 1, 10, &stale_result), CBM_STORE_OK); + ASSERT_TRUE(stale_result.pagerank_stale); + ASSERT_EQ(stale_result.visited_count, 1); + ASSERT_FLOAT_EQ(stale_result.visited[0].pagerank_score, 0.0, CBM_PAGERANK_EPSILON); + + cbm_store_traverse_free(&stale_result); + + trail_graph = NULL; + ASSERT_EQ(cbm_store_trail_graph_load(s, "test", "outbound", types, 1, &trail_graph), + CBM_STORE_OK); + trail_work = 0; + trail_limit_hit = false; + trail_cancelled = false; + memset(&trail_result, 0, sizeof(trail_result)); + ASSERT_EQ(cbm_store_trail_graph_traverse(trail_graph, idA, NULL, 1, 1, 10, NULL, NULL, + &trail_result, &trail_work, &trail_limit_hit, + &trail_cancelled), + CBM_STORE_OK); + ASSERT_TRUE(trail_result.pagerank_stale); + ASSERT_EQ(trail_result.visited_count, 1); + ASSERT_FLOAT_EQ(trail_result.visited[0].pagerank_score, 0.0, CBM_PAGERANK_EPSILON); + cbm_store_traverse_free(&trail_result); + cbm_store_trail_graph_free(trail_graph); + + cbm_store_close(s); + PASS(); +} + +TEST(store_trail_graph_snapshot_is_project_scoped) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "selected", "/tmp/selected"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(s, "foreign", "/tmp/foreign"), CBM_STORE_OK); + + cbm_node_t selected = {.project = "selected", + .label = "Function", + .name = "selected", + .qualified_name = "selected.fn"}; + cbm_node_t foreign = {.project = "foreign", + .label = "Function", + .name = "foreign", + .qualified_name = "foreign.fn"}; + int64_t selected_id = cbm_store_upsert_node(s, &selected); + int64_t foreign_id = cbm_store_upsert_node(s, &foreign); + ASSERT_GT(selected_id, 0); + ASSERT_GT(foreign_id, 0); + + /* Deliberately malformed ownership: a foreign-project edge connects the + * selected-project node. Project-scoped Cypher must not traverse it. */ + cbm_edge_t foreign_edge = { + .project = "foreign", .source_id = selected_id, .target_id = foreign_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &foreign_edge), 0); + + const char *types[] = {"CALLS"}; + cbm_store_trail_graph_t *graph = NULL; + ASSERT_EQ(cbm_store_trail_graph_load(s, "selected", "outbound", types, 1, &graph), + CBM_STORE_OK); + cbm_traverse_result_t result = {0}; + int work_rows = 0; + bool work_limit_hit = false; + bool cancelled = false; + ASSERT_EQ(cbm_store_trail_graph_traverse(graph, selected_id, NULL, 1, 1, 10, NULL, NULL, + &result, &work_rows, &work_limit_hit, &cancelled), + CBM_STORE_OK); + ASSERT_EQ(result.visited_count, 0); + ASSERT_EQ(work_rows, 0); + ASSERT_FALSE(work_limit_hit); + ASSERT_FALSE(cancelled); + + cbm_store_traverse_free(&result); + cbm_store_trail_graph_free(graph); + cbm_store_close(s); + PASS(); +} + +typedef struct { + bool *used; + int edge_count; + int visits; + bool fail; +} store_trail_visit_test_ctx_t; + +static int store_trail_visit_test_cb(const cbm_node_t *node, const cbm_edge_t *last_edge, + void *userdata) { + store_trail_visit_test_ctx_t *ctx = userdata; + if (!node || !last_edge) { + return CBM_STORE_ERR; + } + int used_count = 0; + for (int i = 0; i < ctx->edge_count; i++) { + used_count += ctx->used[i] ? 1 : 0; + } + if (used_count <= 0) { + return CBM_STORE_ERR; + } + ctx->visits++; + return ctx->fail ? CBM_STORE_ERR : CBM_STORE_OK; +} + +static bool store_trail_cancel_immediately(void *userdata) { + (void)userdata; + return true; +} + +TEST(store_trail_graph_visit_unwinds_used_edges_on_every_exit) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + cbm_node_t a = { + .project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"}; + cbm_node_t b = { + .project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"}; + cbm_node_t c = { + .project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"}; + int64_t a_id = cbm_store_upsert_node(s, &a); + int64_t b_id = cbm_store_upsert_node(s, &b); + int64_t c_id = cbm_store_upsert_node(s, &c); + cbm_edge_t ab = {.project = "test", .source_id = a_id, .target_id = b_id, .type = "CALLS"}; + cbm_edge_t bc = {.project = "test", .source_id = b_id, .target_id = c_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &ab), 0); + ASSERT_GT(cbm_store_insert_edge(s, &bc), 0); + + cbm_store_trail_graph_t *graph = NULL; + ASSERT_EQ(cbm_store_trail_graph_load(s, "test", "any", NULL, 0, &graph), CBM_STORE_OK); + int edge_count = cbm_store_trail_graph_edge_count(graph); + ASSERT_EQ(edge_count, 2); + ASSERT_EQ(cbm_store_trail_graph_arc_count(graph), 4); + bool used[2] = {false, false}; + const char *types[] = {"CALLS"}; + int work_rows = 0; + bool work_limit_hit = false; + bool cancelled = false; + store_trail_visit_test_ctx_t ctx = { + .used = used, .edge_count = edge_count, .visits = 0, .fail = false}; + + ASSERT_EQ(cbm_store_trail_graph_visit(graph, a_id, NULL, "outbound", types, 1, 1, 2, used, 10, + &work_rows, NULL, NULL, store_trail_visit_test_cb, &ctx, + &work_limit_hit, &cancelled), + CBM_STORE_OK); + ASSERT_EQ(ctx.visits, 2); + ASSERT_FALSE(used[0]); + ASSERT_FALSE(used[1]); + + memset(used, 0, sizeof(used)); + work_rows = 0; + work_limit_hit = false; + cancelled = false; + ctx.visits = 0; + ctx.fail = true; + ASSERT_EQ(cbm_store_trail_graph_visit(graph, a_id, NULL, "outbound", types, 1, 1, 2, used, 10, + &work_rows, NULL, NULL, store_trail_visit_test_cb, &ctx, + &work_limit_hit, &cancelled), + CBM_STORE_ERR); + ASSERT_FALSE(used[0]); + ASSERT_FALSE(used[1]); + + memset(used, 0, sizeof(used)); + work_rows = 0; + work_limit_hit = false; + cancelled = false; + ctx.visits = 0; + ctx.fail = false; + ASSERT_EQ(cbm_store_trail_graph_visit(graph, a_id, NULL, "outbound", types, 1, 1, 2, used, 1, + &work_rows, NULL, NULL, store_trail_visit_test_cb, &ctx, + &work_limit_hit, &cancelled), + CBM_STORE_OK); + ASSERT_TRUE(work_limit_hit); + ASSERT_FALSE(used[0]); + ASSERT_FALSE(used[1]); + + /* A nested segment shares the query-wide work counter. If an earlier + * segment already exhausted or crossed the budget, exact equality is too + * fragile: the visitor must fail closed without examining another arc and + * must leave every caller-owned edge mark unchanged. */ + memset(used, 0, sizeof(used)); + work_rows = 2; + work_limit_hit = false; + cancelled = false; + ctx.visits = 0; + ASSERT_EQ(cbm_store_trail_graph_visit(graph, a_id, NULL, "outbound", types, 1, 1, 2, used, 1, + &work_rows, NULL, NULL, store_trail_visit_test_cb, &ctx, + &work_limit_hit, &cancelled), + CBM_STORE_OK); + ASSERT_TRUE(work_limit_hit); + ASSERT_EQ(work_rows, 2); + ASSERT_EQ(ctx.visits, 0); + ASSERT_FALSE(used[0]); + ASSERT_FALSE(used[1]); + + memset(used, 0, sizeof(used)); + work_rows = 0; + work_limit_hit = false; + cancelled = false; + ASSERT_EQ(cbm_store_trail_graph_visit(graph, a_id, NULL, "outbound", types, 1, 1, 2, used, 10, + &work_rows, store_trail_cancel_immediately, NULL, + store_trail_visit_test_cb, &ctx, &work_limit_hit, + &cancelled), + CBM_STORE_OK); + ASSERT_TRUE(cancelled); + ASSERT_FALSE(used[0]); + ASSERT_FALSE(used[1]); + + cbm_store_trail_graph_free(graph); + graph = NULL; + ASSERT_EQ(cbm_store_trail_graph_load(s, "test", "outbound", NULL, 0, &graph), CBM_STORE_OK); + ASSERT_EQ(cbm_store_trail_graph_edge_count(graph), 2); + ASSERT_EQ(cbm_store_trail_graph_arc_count(graph), 2); + cbm_store_trail_graph_free(graph); + cbm_store_close(s); + PASS(); +} + +TEST(store_search_uses_legacy_but_not_stale_pagerank) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t na = { + .project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"}; + cbm_node_t nb = { + .project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"}; + int64_t idA = cbm_store_upsert_node(s, &na); + int64_t idB = cbm_store_upsert_node(s, &nb); + + char rank_sql[512]; + snprintf(rank_sql, sizeof(rank_sql), + "INSERT INTO pagerank(project,node_id,rank,computed_at) " + "VALUES('test',%lld,0.1,'2026-06-30T00:00:00Z')," + "('test',%lld,0.9,'2026-06-30T00:00:00Z')", + (long long)idA, (long long)idB); + ASSERT_EQ(cbm_store_exec(s, rank_sql), CBM_STORE_OK); + + cbm_search_params_t params = {0}; + params.project = "test"; + params.limit = 2; + params.min_degree = -1; + params.max_degree = -1; + cbm_search_output_t out = {0}; + ASSERT_EQ(cbm_store_search(s, ¶ms, &out), CBM_STORE_OK); + ASSERT_FALSE(out.pagerank_stale); + ASSERT_EQ(out.count, 2); + ASSERT_STR_EQ(out.results[0].node.name, "B"); + ASSERT_FLOAT_EQ(out.results[0].pagerank_score, 0.9, CBM_PAGERANK_EPSILON); + cbm_store_search_free(&out); + + ASSERT_EQ(cbm_store_set_derived_view_state(s, "test", CBM_STORE_DERIVED_VIEW_PAGERANK, + CBM_STORE_DERIVED_GENERATION_UNKNOWN, + CBM_STORE_DERIVED_STATUS_STALE), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_search(s, ¶ms, &out), CBM_STORE_OK); + ASSERT_TRUE(out.pagerank_stale); + ASSERT_EQ(out.count, 2); + ASSERT_STR_EQ(out.results[0].node.name, "A"); + ASSERT_FLOAT_EQ(out.results[0].pagerank_score, 0.0, CBM_PAGERANK_EPSILON); + + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + /* ── BFS cross-service summary ─────────────────────────────────── */ TEST(store_bfs_cross_service_summary) { @@ -1199,6 +1545,111 @@ TEST(store_batch_count_degrees) { PASS(); } +/* ── SQL injection resistance for exclude_labels ─────────────────── */ +/* TDD test for C3: origin/main commit 6a6127c switched exclude_labels + * and edge_types filter clauses to sqlite3_bind_text() parameterized + * binding. This test verifies that a SQL injection payload in an + * exclude_labels value cannot corrupt or destroy the database. + * + * With the old snprintf approach [api-consolidation store.c:2008-2019] + * a value like "') DROP TABLE nodes; --" would be interpolated directly + * into the SQL string and could be executed. With bind params the value + * is treated as a literal string and cannot break out of the IN clause. + */ +TEST(store_search_exclude_labels_sqli) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t n1 = {.project = "test", + .label = "Function", + .name = "safe_fn", + .qualified_name = "test.safe_fn", + .file_path = "a.go"}; + cbm_node_t n2 = {.project = "test", + .label = "Class", + .name = "SafeClass", + .qualified_name = "test.SafeClass", + .file_path = "b.go"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + + /* SQL injection payload in the exclude_labels array. With snprintf + * interpolation [api-consolidation store.c:2015] this would produce: + * n.label NOT IN ('') DROP TABLE nodes; --') + * which breaks out of the literal and executes a DROP TABLE. + * With bind params the payload is a literal string comparison and + * cannot execute arbitrary SQL. */ + const char *excl[] = {"') DROP TABLE nodes; --", NULL}; + cbm_search_params_t params = {.project = "test", + .limit = 100, + .min_degree = -1, + .max_degree = -1, + .exclude_labels = excl}; + cbm_search_output_t out = {0}; + int rc = cbm_store_search(s, ¶ms, &out); + + /* The search must not fail — parameterized binding ensures the + * injection payload is treated as a literal string, not SQL. */ + ASSERT_EQ(rc, CBM_STORE_OK); + /* Both nodes (Function, Class) should still be present — nodes + * table must NOT have been dropped by the injection attempt. */ + ASSERT_EQ(out.total, 2); + cbm_store_search_free(&out); + + /* Double-check: search again with no exclusions to confirm table intact */ + cbm_search_params_t params2 = { + .project = "test", .limit = 100, .min_degree = -1, .max_degree = -1}; + cbm_search_output_t out2 = {0}; + rc = cbm_store_search(s, ¶ms2, &out2); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(out2.total, 2); + cbm_store_search_free(&out2); + + cbm_store_close(s); + PASS(); +} + +/* ── SQL injection resistance for BFS edge_types ─────────────────── */ +/* Same principle: verify that a SQL injection payload in an edge_types + * value passed to cbm_store_bfs() cannot corrupt the database. + * + * The types_clause in [api-consolidation store.c:2258-2268] builds + * 'CALLS','IMPORTS' + * with snprintf. A payload like "','') DROP TABLE edges; --" would + * break out of the IN clause with the old approach. With bind params + * it is treated as a literal and causes no harm. */ +TEST(store_bfs_edge_types_sqli) { + int64_t ids[3]; + cbm_store_t *s = setup_search_store(ids); + + /* SQL injection payload as an edge type. + * cbm_store_bfs signature [api-consolidation src/store/store.h:365]: + * int cbm_store_bfs(cbm_store_t *s, int64_t start_id, + * const char *direction, const char **edge_types, + * int edge_type_count, int max_depth, + * int max_results, cbm_traverse_result_t *out); */ + const char *edge_types_sqli[] = {"','') DROP TABLE edges; --"}; + cbm_traverse_result_t result = {0}; + int rc = cbm_store_bfs(s, ids[0], "outbound", edge_types_sqli, 1, 3, 50, &result); + + /* Must not crash or corrupt the database. The injection payload + * matches no real edge type, so we expect 0 visited but CBM_STORE_OK. */ + ASSERT_TRUE(rc == CBM_STORE_OK || rc == CBM_STORE_NOT_FOUND); + + /* Verify edges table still intact: BFS with a real edge type must work */ + cbm_store_traverse_free(&result); + cbm_traverse_result_t result2 = {0}; + const char *real_types[] = {"CALLS"}; + rc = cbm_store_bfs(s, ids[0], "outbound", real_types, 1, 3, 50, &result2); + ASSERT_EQ(rc, CBM_STORE_OK); + /* Should find ids[1] (ProcessOrder) */ + ASSERT_GTE(result2.visited_count, 1); + cbm_store_traverse_free(&result2); + + cbm_store_close(s); + PASS(); +} + /* ── GlobToLike edge cases ──────────────────────────────────────── */ TEST(store_glob_to_like_empty) { @@ -1482,6 +1933,7 @@ TEST(store_find_nodes_rejects_null_store_without_ub) { SUITE(store_search) { RUN_TEST(store_search_by_label); + RUN_TEST(store_search_summary_returns_exact_facets_without_node_rows); RUN_TEST(store_search_by_name_pattern); RUN_TEST(store_search_empty_label_ignored); RUN_TEST(store_search_by_file_pattern); @@ -1509,12 +1961,19 @@ SUITE(store_search) { RUN_TEST(store_cross_service_detection); RUN_TEST(store_deduplicate_hops); RUN_TEST(store_bfs_with_risk_labels); + RUN_TEST(store_bfs_carries_joined_pagerank_score); + RUN_TEST(store_trail_graph_snapshot_is_project_scoped); + RUN_TEST(store_trail_graph_visit_unwinds_used_edges_on_every_exit); + RUN_TEST(store_search_uses_legacy_but_not_stale_pagerank); RUN_TEST(store_bfs_cross_service_summary); RUN_TEST(store_glob_to_like); RUN_TEST(store_extract_like_hints); RUN_TEST(store_ensure_case_insensitive); RUN_TEST(store_strip_case_flag); RUN_TEST(store_batch_count_degrees); + /* SQL injection resistance tests (parameterized bind) */ + RUN_TEST(store_search_exclude_labels_sqli); + RUN_TEST(store_bfs_edge_types_sqli); /* Edge case tests */ RUN_TEST(store_glob_to_like_empty); RUN_TEST(store_glob_to_like_only_star); diff --git a/tests/test_str_util.c b/tests/test_str_util.c index cd6f54210..27082c779 100644 --- a/tests/test_str_util.c +++ b/tests/test_str_util.c @@ -103,6 +103,22 @@ TEST(str_contains) { PASS(); } +TEST(str_copy_fixed_buffer) { + char buf[6] = {'x', 'x', 'x', 'x', 'x', 'x'}; + ASSERT_TRUE(cbm_str_copy(buf, sizeof(buf), "hello")); + ASSERT_STR_EQ(buf, "hello"); + + ASSERT_FALSE(cbm_str_copy(buf, sizeof(buf), "hello world")); + ASSERT_STR_EQ(buf, "hello"); + + ASSERT_TRUE(cbm_str_copy(buf, sizeof(buf), NULL)); + ASSERT_STR_EQ(buf, ""); + + ASSERT_FALSE(cbm_str_copy(NULL, sizeof(buf), "x")); + ASSERT_FALSE(cbm_str_copy(buf, 0, "x")); + PASS(); +} + TEST(str_tolower) { setup(); ASSERT_STR_EQ(cbm_str_tolower(&a, "Hello World"), "hello world"); @@ -352,6 +368,11 @@ TEST(path_base_trailing_slash) { PASS(); } +TEST(path_base_backslash_separator) { + ASSERT_STR_EQ(cbm_path_base("dir\\package.json"), "package.json"); + PASS(); +} + /* ── validate_shell_arg tests ─────────────────────────────────── */ TEST(validate_shell_arg_null) { @@ -424,15 +445,38 @@ TEST(validate_shell_arg_spaces) { PASS(); } -/* ── JSON Escaping tests ──────────────────────────────────────── */ - TEST(json_escape_control_chars) { char buf[64]; - const char *input = "A\x01" - "B\n"; + const char input[] = {'A', 0x01, 'B', '\n', 0x1f, '\0'}; + int len = cbm_json_escape(buf, sizeof(buf), input); - ASSERT_STR_EQ(buf, "A\\u0001B\\n"); - ASSERT_EQ(len, 10); + + ASSERT_STR_EQ(buf, "A\\u0001B\\n\\u001f"); + ASSERT_EQ(len, 16); + PASS(); +} + +TEST(json_escaped_len_matches_writer) { + const char input[] = {'A', '"', '\\', '\n', '\r', '\t', 0x01, 0x1f, 'Z', '\0'}; + char buf[64]; + + size_t needed = cbm_json_escaped_len(input); + int written = cbm_json_escape(buf, sizeof(buf), input); + + ASSERT_EQ(needed, 24); + ASSERT_EQ((size_t)written, needed); + ASSERT_EQ(strlen(buf), needed); + ASSERT_EQ(cbm_json_escaped_len(""), 0); + ASSERT_EQ(cbm_json_escaped_len(NULL), 0); + + for (int byte = 1; byte <= 0xff; byte++) { + char single[] = {(char)byte, '\0'}; + char escaped[8]; + size_t single_needed = cbm_json_escaped_len(single); + int single_written = cbm_json_escape(escaped, sizeof(escaped), single); + ASSERT_EQ((size_t)single_written, single_needed); + ASSERT_EQ(strlen(escaped), single_needed); + } PASS(); } @@ -493,6 +537,7 @@ SUITE(str_util) { RUN_TEST(str_starts_with); RUN_TEST(str_ends_with); RUN_TEST(str_contains); + RUN_TEST(str_copy_fixed_buffer); RUN_TEST(str_tolower); RUN_TEST(str_replace_char); RUN_TEST(str_strip_ext); @@ -531,6 +576,7 @@ SUITE(str_util) { RUN_TEST(path_base_empty); RUN_TEST(path_base_just_filename); RUN_TEST(path_base_trailing_slash); + RUN_TEST(path_base_backslash_separator); /* validate_shell_arg */ RUN_TEST(validate_shell_arg_null); RUN_TEST(validate_shell_arg_safe); @@ -547,6 +593,7 @@ SUITE(str_util) { RUN_TEST(validate_shell_arg_spaces); /* JSON Escaping */ RUN_TEST(json_escape_control_chars); + RUN_TEST(json_escaped_len_matches_writer); /* SNPRINTF_APPEND */ RUN_TEST(snprintf_append_basic); RUN_TEST(snprintf_append_fills_exactly); diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index 39a4b1249..f679f6d9e 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -9,9 +9,11 @@ * (SKIP_PLATFORM on Windows, which lacks it). */ #include "test_framework.h" +#include "../src/foundation/platform.h" +#include "../src/foundation/platform_internal.h" #include "../src/foundation/subprocess.h" #include "../src/foundation/compat.h" -#include "../src/foundation/platform.h" +#include "../src/foundation/compat_fs.h" /* cbm_fopen */ #include #include @@ -21,6 +23,7 @@ #ifndef _WIN32 #include #include +#include #include #endif @@ -109,6 +112,70 @@ TEST(subprocess_run_clean) { #endif } +TEST(subprocess_short_child_uses_fast_reap_window) { + ASSERT_EQ(cbm_subprocess_poll_interval_ms(0, 100), 5); + ASSERT_EQ(cbm_subprocess_poll_interval_ms(249, 100), 5); + ASSERT_EQ(cbm_subprocess_poll_interval_ms(250, 100), 100); + ASSERT_EQ(cbm_subprocess_poll_interval_ms(250, 200), 200); +#ifdef _WIN32 + ASSERT_EQ(cbm_subprocess_poll_interval_ms(250, CBM_SUBPROCESS_USE_PLATFORM_POLL_INTERVAL), 200); +#else + ASSERT_EQ(cbm_subprocess_poll_interval_ms(250, CBM_SUBPROCESS_USE_PLATFORM_POLL_INTERVAL), 100); +#endif +#ifdef _WIN32 + SKIP_PLATFORM("POSIX /bin/sh latency canary; poll policy assertions ran"); +#elif defined(__SANITIZE_THREAD__) || __has_feature(thread_sanitizer) + SKIP_PLATFORM("wall-clock fork/exec canary is invalid under TSan; poll policy assertions ran"); +#else + /* Coarse regression canary only: the old path unconditionally slept a full + * steady interval after observing a still-running child. Exact policy is + * covered by the deterministic assertions above. + * + * Two things this deliberately does NOT do, because both made it fail on + * correct behavior: + * - it does not bound elapsed time by the steady interval itself. The child + * sleeps CANARY_CHILD_SLEEP_MS and the regression ADDS a steady interval, + * so the discriminating threshold is their SUM. Bounding by the interval + * alone left zero margin for fork/exec overhead and rejected a correct + * run that measured exactly on the boundary ("elapsed_ms (100) not < 100"). + * - it does not trust a single sample. Under concurrent build and suite load + * scheduler delay alone exceeded the budget (168 ms observed). The MINIMUM + * of a few attempts approximates the true latency, while a real regression + * slows every attempt and is still caught. */ + enum { + CANARY_CHILD_SLEEP_MS = 20, /* matches "sleep 0.02" below */ + CANARY_STEADY_SLEEP_MS = 100, /* CBM_PROC_POSIX_STEADY_POLL_MS */ + /* GROSS-regression guard only, and the margin is the point. Under the + * sanitizer, fork/exec overhead on macOS is itself of the same order as + * CANARY_STEADY_SLEEP_MS, so a budget set AT child+steady cannot separate + * "slept one steady interval" from "paid sanitizer overhead": a correct + * run was measured at exactly 120 ms against a 120 ms budget. Doubling + * the steady term buys margin that overhead cannot cross, while a real + * regression -- which sleeps a steady interval on EVERY poll, not once -- + * still blows through it. Precise policy is pinned deterministically by + * the cbm_subprocess_poll_interval_ms assertions above; this check exists + * only so a wholesale return to sleep-per-poll cannot pass unnoticed. */ + CANARY_BUDGET_MS = CANARY_CHILD_SLEEP_MS + (2 * CANARY_STEADY_SLEEP_MS), + CANARY_ATTEMPTS = 3, + }; + uint64_t best_ms = UINT64_MAX; + for (int attempt = 0; attempt < CANARY_ATTEMPTS; attempt++) { + uint64_t started_ms = cbm_now_ms(); + cbm_proc_result_t r = run_sh("sleep 0.02", 0); + uint64_t elapsed_ms = cbm_now_ms() - started_ms; + ASSERT_EQ(r.outcome, CBM_PROC_CLEAN); + if (elapsed_ms < best_ms) { + best_ms = elapsed_ms; + } + if (best_ms < CANARY_BUDGET_MS) { + break; /* already proved the fast path; no need to pay for more runs */ + } + } + ASSERT_LT(best_ms, CANARY_BUDGET_MS); + PASS(); +#endif +} + TEST(subprocess_run_exit_nonzero) { #ifdef _WIN32 SKIP_PLATFORM("POSIX /bin/sh spawn"); @@ -168,6 +235,46 @@ TEST(subprocess_run_hang_is_hang) { #endif } +/* A cancellation request must terminate and reap a live child without waiting + * for the quiet-timeout. + * + * Migrated from the retired opts.cancel_requested flag: the merged subprocess + * API moves cancellation onto the OWNED HANDLE (cbm_subprocess_request_cancel), + * so the flag no longer exists. The assertion is unchanged -- a cancelled + * `sleep 30` is reaped promptly and classified CBM_PROC_KILLED -- and the handle + * form additionally proves cancel works on an ALREADY-RUNNING child, which the + * pre-set flag could not: it was only ever observed on the first poll. */ +TEST(subprocess_run_cancel_reaps_child) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX /bin/sh spawn"); +#else + const char *argv[] = {"/bin/sh", "-c", "sleep 30", NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + cbm_subprocess_t *process = NULL; + ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0); + ASSERT_NOT_NULL(process); + ASSERT_TRUE(cbm_subprocess_request_cancel(process)); + + cbm_proc_result_t r = {0}; + cbm_proc_poll_t polled = CBM_PROC_POLL_RUNNING; + /* Bounded: a cancelled child must reach terminal well inside `sleep 30`, so + * a timeout here is a real failure rather than a slow machine. */ + for (int i = 0; i < 2000 && polled == CBM_PROC_POLL_RUNNING; i++) { + polled = cbm_subprocess_poll(process, &r); + if (polled == CBM_PROC_POLL_RUNNING) { + cbm_usleep(5000); + } + } + cbm_subprocess_destroy(process); + + ASSERT_EQ(polled, CBM_PROC_POLL_TERMINAL); + ASSERT_EQ(r.outcome, CBM_PROC_KILLED); + PASS(); +#endif +} + /* A spawn of a non-existent binary fails cleanly (no child), not a crash. */ TEST(subprocess_run_spawn_failure) { #ifdef _WIN32 @@ -214,6 +321,42 @@ static void subprocess_test_pause(void) { (void)cbm_nanosleep(&delay, NULL); } +#if defined(CBM_ENABLE_TEST_SEAMS) && defined(__APPLE__) +enum { CBM_DARWIN_ZOMBIE_OBSERVE_TIMEOUT_MS = 1000 }; +static bool darwin_post_spawn_observed_zombie; + +static void hold_darwin_managed_child_as_zombie(long child_pid) { + uint64_t deadline = cbm_now_ms() + CBM_DARWIN_ZOMBIE_OBSERVE_TIMEOUT_MS; + do { + siginfo_t info; + memset(&info, 0, sizeof(info)); + if (waitid(P_PID, (id_t)child_pid, &info, WEXITED | WNOHANG | WNOWAIT) == 0 && + info.si_pid == (pid_t)child_pid) { + darwin_post_spawn_observed_zombie = true; + return; + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline); +} + +TEST(subprocess_darwin_managed_spawn_accepts_immediate_exit_zombie) { + darwin_post_spawn_observed_zombie = false; + cbm_subprocess_set_darwin_post_spawn_hook_for_testing(hold_darwin_managed_child_as_zombie); + + cbm_proc_opts_t opts = {0}; + opts.bin = "/usr/bin/true"; + cbm_proc_result_t result = {0}; + int run_rc = cbm_subprocess_run(&opts, &result); + + cbm_subprocess_set_darwin_post_spawn_hook_for_testing(NULL); + ASSERT_TRUE(darwin_post_spawn_observed_zombie); + ASSERT_EQ(run_rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(result.tree_quiesced); + PASS(); +} +#endif + static bool poll_until_terminal(cbm_subprocess_t *process, int timeout_ms, cbm_proc_result_t *out) { uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; do { @@ -236,7 +379,7 @@ static bool make_tree_pid_path(char path[64]) { return false; } (void)close(fd); - return unlink(path) == 0; /* child creates it only after both traps are installed */ + return unlink(path) == 0; /* probe recreates it only after its signal traps are installed */ } static bool wait_for_tree_pids(const char *path, cbm_subprocess_t *process, pid_t *parent_pid, @@ -264,6 +407,29 @@ static bool wait_for_tree_pids(const char *path, cbm_subprocess_t *process, pid_ return false; } +static bool wait_for_process_pid(const char *path, cbm_subprocess_t *process, pid_t *pid, + int timeout_ms) { + uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; + do { + FILE *f = fopen(path, "r"); + if (f) { + long value = 0; + int fields = fscanf(f, "%ld", &value); + fclose(f); + if (fields == 1 && value > 1) { + *pid = (pid_t)value; + return true; + } + } + cbm_proc_result_t ignored; + if (cbm_subprocess_poll(process, &ignored) != CBM_PROC_POLL_RUNNING) { + return false; + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline); + return false; +} + static bool wait_pid_gone(pid_t pid, int timeout_ms) { uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; do { @@ -307,6 +473,70 @@ static int spawn_ignoring_tree(const char *pid_path, int quiet_timeout_ms, int c return cbm_subprocess_spawn(&opts, out); } +static int spawn_ignoring_process(const char *pid_path, int cancel_grace_ms, + cbm_subprocess_t **out) { + /* Keep this probe to one owned process. The zombie-only assertion must not + * depend on when the host init process reaps an orphaned grandchild. Ignored + * signal dispositions survive exec, so /bin/sleep remains TERM-resistant. */ + const char *script = "trap '' TERM; echo \"$$\" > \"$1\"; exec /bin/sleep 60"; + const char *argv[] = {"/bin/sh", "-c", script, "cbm-process", pid_path, NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.cancel_grace_ms = cancel_grace_ms; + return cbm_subprocess_spawn(&opts, out); +} + +static pid_t create_zombie_group_member(pid_t pgid) { + int gate[2]; + if (pipe(gate) != 0) { + return -1; + } + pid_t pid = fork(); + if (pid < 0) { + (void)close(gate[0]); + (void)close(gate[1]); + return -1; + } + if (pid == 0) { + (void)close(gate[1]); + char ignored; + ssize_t received; + do { + received = read(gate[0], &ignored, sizeof(ignored)); + } while (received < 0 && errno == EINTR); + (void)close(gate[0]); + _exit(received == 0 ? 0 : 101); + } + (void)close(gate[0]); + if (setpgid(pid, pgid) != 0) { + (void)close(gate[1]); + (void)kill(pid, SIGKILL); + (void)waitpid(pid, NULL, 0); + return -1; + } + (void)close(gate[1]); /* EOF releases the child; deliberately do not reap it yet */ + + uint64_t deadline = cbm_now_ms() + 1000U; + do { + siginfo_t info; + memset(&info, 0, sizeof(info)); + if (waitid(P_PID, (id_t)pid, &info, WEXITED | WNOHANG | WNOWAIT) == 0 && + info.si_pid == pid) { + return pid; + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline); + (void)kill(pid, SIGKILL); + (void)waitpid(pid, NULL, 0); + return -1; +} + +static bool subprocess_zombie_process_table_available(void) { + return cbm_platform_process_group_state((int64_t)getpgrp()) == + CBM_PLATFORM_PROCESS_GROUP_ACTIVE; +} + typedef struct { cbm_subprocess_t *process; int count; @@ -555,6 +785,55 @@ TEST(subprocess_cancel_grace_is_hard_capped) { #endif } +TEST(subprocess_zombie_only_group_is_quiesced_without_extending_settle) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX zombie/process-group probe; Windows Job Objects exclude exited processes"); +#else + if (!subprocess_zombie_process_table_available()) { + SKIP_PLATFORM("host denies the process-table query required to classify zombie-only groups"); + } + char pid_path[64]; + ASSERT_TRUE(make_tree_pid_path(pid_path)); + cbm_subprocess_t *process = NULL; + ASSERT_EQ(spawn_ignoring_process(pid_path, 100, &process), 0); + ASSERT_NOT_NULL(process); + + pid_t parent_pid = -1; + bool ready = wait_for_process_pid(pid_path, process, &parent_pid, 1000); + /* This direct test child joins the owned PGID, exits, and remains deliberately + * unreaped. kill(-pgid, 0) therefore keeps succeeding past the force deadline + * after the single owned process is reaped and no group member can execute. */ + pid_t zombie_pid = ready ? create_zombie_group_member(parent_pid) : -1; + bool cancel_accepted = zombie_pid > 1 && cbm_subprocess_request_cancel(process); + cbm_proc_result_t result = {0}; + bool terminal = cancel_accepted && poll_until_terminal(process, 2500, &result); + int zombie_status = 0; + bool zombie_reaped = zombie_pid > 1 && waitpid(zombie_pid, &zombie_status, 0) == zombie_pid; + if (!terminal) { + force_probe_cleanup(parent_pid, -1); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } + } else { + cbm_subprocess_destroy(process); + } + (void)unlink(pid_path); + + ASSERT_TRUE(ready); + ASSERT_TRUE(zombie_pid > 1); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(terminal); + ASSERT_TRUE(result.forced); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_FALSE(result.supervision_failed); + ASSERT_TRUE(zombie_reaped); + ASSERT_TRUE(WIFEXITED(zombie_status)); + ASSERT_EQ(WEXITSTATUS(zombie_status), 0); + PASS(); +#endif +} + TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless) { #ifdef _WIN32 SKIP_PLATFORM("POSIX shell log budget probe; native Windows coverage pending"); @@ -732,6 +1011,50 @@ TEST(subprocess_posix_child_closes_unrelated_descriptors) { #endif } +TEST(subprocess_posix_fd_close_limit_is_finite) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX descriptor-close bound"); +#else + long close_limit = cbm_subprocess_posix_fd_close_limit(); + ASSERT_TRUE(close_limit > STDERR_FILENO); + ASSERT_TRUE(close_limit <= INT_MAX); + PASS(); +#endif +} + +TEST(subprocess_posix_child_resets_signal_disposition_and_mask) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX signal disposition/mask probe"); +#else + struct sigaction ignored = {0}; + struct sigaction original_action; + ignored.sa_handler = SIG_IGN; + ASSERT_EQ(sigemptyset(&ignored.sa_mask), 0); + ASSERT_EQ(sigaction(SIGTERM, &ignored, &original_action), 0); + sigset_t blocked; + sigset_t original_mask; + ASSERT_EQ(sigemptyset(&blocked), 0); + ASSERT_EQ(sigaddset(&blocked, SIGTERM), 0); + ASSERT_EQ(sigprocmask(SIG_BLOCK, &blocked, &original_mask), 0); + + const char *argv[] = {"/bin/sh", "-c", "kill -TERM $$; exit 42", NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + cbm_proc_result_t result = {0}; + int run_rc = cbm_subprocess_run(&opts, &result); + + int mask_restore = sigprocmask(SIG_SETMASK, &original_mask, NULL); + int action_restore = sigaction(SIGTERM, &original_action, NULL); + ASSERT_EQ(mask_restore, 0); + ASSERT_EQ(action_restore, 0); + ASSERT_EQ(run_rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_KILLED); + ASSERT_EQ(result.term_signal, SIGTERM); + PASS(); +#endif +} + TEST(subprocess_root_exit_drains_surviving_descendant) { #ifdef _WIN32 SKIP_PLATFORM("POSIX process-group descendant probe; native Windows coverage pending"); @@ -980,7 +1303,84 @@ TEST(win_cmd_payload_rejects_short_relative_and_non_cmd_paths) { PASS(); } +#ifndef _WIN32 +/* Read a whole log file so both directions can be asserted on its exact bytes. */ +static bool subprocess_read_log(const char *path, char *out, size_t out_size) { + FILE *f = cbm_fopen(path, "r"); /* project wrapper: wide-path handling on Windows */ + if (!f) { + return false; + } + size_t n = fread(out, 1, out_size - 1, f); + out[n] = '\0'; + (void)fclose(f); + return true; +} + +/* Run a child that writes one line to stdout and a different line to stderr. */ +static bool subprocess_run_two_stream_child(const char *log_path, bool discard_stderr, + cbm_proc_result_t *result) { + const char *script = "echo CBM_STDOUT_LINE; echo CBM_STDERR_LINE 1>&2"; + const char *argv[] = {"/bin/sh", "-c", script, NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.log_file = log_path; + opts.discard_stderr = discard_stderr; + opts.cancel_grace_ms = 100; + return cbm_subprocess_run(&opts, result) == 0; +} +#endif + +/* discard_stderr exists so a caller that PARSES child output cannot read a + * diagnostic as a result: src/git/git_command.c captures a command's first line + * and must never return "warning: ..." as, say, a rev-parse value. Both + * directions are asserted, because the DEFAULT merged stream is itself a + * capability -- the index supervisor tails child logs and needs stderr in them. */ +TEST(subprocess_discard_stderr_keeps_stdout_and_drops_diagnostics) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX two-stream probe; native Windows coverage pending"); +#else + char merged_path[] = "/tmp/cbm-subprocess-stderr-merged-XXXXXX"; + int merged_fd = cbm_mkstemp(merged_path); + ASSERT_TRUE(merged_fd >= 0); + (void)close(merged_fd); + char split_path[] = "/tmp/cbm-subprocess-stderr-split-XXXXXX"; + int split_fd = cbm_mkstemp(split_path); + ASSERT_TRUE(split_fd >= 0); + (void)close(split_fd); + + /* Default: both streams reach the log, which log tailing depends on. */ + cbm_proc_result_t merged_result = {0}; + bool merged_ran = subprocess_run_two_stream_child(merged_path, false, &merged_result); + char merged_text[1024] = ""; + bool merged_read = merged_ran && subprocess_read_log(merged_path, merged_text, + sizeof(merged_text)); + + /* discard_stderr: stdout survives, the diagnostic is gone. */ + cbm_proc_result_t split_result = {0}; + bool split_ran = subprocess_run_two_stream_child(split_path, true, &split_result); + char split_text[1024] = ""; + bool split_read = split_ran && subprocess_read_log(split_path, split_text, + sizeof(split_text)); + + (void)unlink(merged_path); + (void)unlink(split_path); + + ASSERT_TRUE(merged_ran); + ASSERT_TRUE(merged_read); + ASSERT_NOT_NULL(strstr(merged_text, "CBM_STDOUT_LINE")); + ASSERT_NOT_NULL(strstr(merged_text, "CBM_STDERR_LINE")); + + ASSERT_TRUE(split_ran); + ASSERT_TRUE(split_read); + ASSERT_NOT_NULL(strstr(split_text, "CBM_STDOUT_LINE")); + ASSERT_NULL(strstr(split_text, "CBM_STDERR_LINE")); + PASS(); +#endif +} + SUITE(subprocess) { + RUN_TEST(subprocess_discard_stderr_keeps_stdout_and_drops_diagnostics); RUN_TEST(subprocess_classify_clean); RUN_TEST(subprocess_classify_exit_nonzero); RUN_TEST(subprocess_classify_windows_crash_codes); @@ -989,10 +1389,15 @@ SUITE(subprocess) { RUN_TEST(subprocess_classify_timeout_dominates); RUN_TEST(subprocess_outcome_str); RUN_TEST(subprocess_run_clean); + RUN_TEST(subprocess_short_child_uses_fast_reap_window); +#if defined(CBM_ENABLE_TEST_SEAMS) && defined(__APPLE__) + RUN_TEST(subprocess_darwin_managed_spawn_accepts_immediate_exit_zombie); +#endif RUN_TEST(subprocess_run_exit_nonzero); RUN_TEST(subprocess_run_resolves_literal_binary_name_from_path); RUN_TEST(subprocess_run_crash_is_crash); RUN_TEST(subprocess_run_hang_is_hang); + RUN_TEST(subprocess_run_cancel_reaps_child); RUN_TEST(subprocess_run_spawn_failure); RUN_TEST(subprocess_run_null_bin_rejected); RUN_TEST(subprocess_spawn_returns_while_child_is_running); @@ -1000,9 +1405,12 @@ SUITE(subprocess) { RUN_TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree); RUN_TEST(subprocess_quiet_timeout_kills_ignoring_tree); RUN_TEST(subprocess_cancel_grace_is_hard_capped); + RUN_TEST(subprocess_zombie_only_group_is_quiesced_without_extending_settle); RUN_TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless); RUN_TEST(subprocess_final_log_drain_error_is_terminal_and_preserves_classification); RUN_TEST(subprocess_posix_child_closes_unrelated_descriptors); + RUN_TEST(subprocess_posix_fd_close_limit_is_finite); + RUN_TEST(subprocess_posix_child_resets_signal_disposition_and_mask); RUN_TEST(subprocess_root_exit_drains_surviving_descendant); RUN_TEST(win_cmdline_index_worker_json); RUN_TEST(win_cmdline_roundtrip_battery); diff --git a/tests/test_summarize_benchmark_results.py b/tests/test_summarize_benchmark_results.py new file mode 100644 index 000000000..5323ae93c --- /dev/null +++ b/tests/test_summarize_benchmark_results.py @@ -0,0 +1,1721 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "benchmarks" / "summarize_results.py" +SPEC = importlib.util.spec_from_file_location("summarize_benchmark_results", SCRIPT) +assert SPEC and SPEC.loader +SUMMARY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SUMMARY) + + +def report(case: dict, sha: str = "a" * 64) -> dict: + return { + "binary_metadata": {"sha256": sha, "size_bytes": 123}, + "parameters": { + "config_profile": "rank_disabled", + "config_overrides": {"rank_enabled": "false"}, + }, + "cleanup": {"requested": True, "removed": True}, + "cases": [case], + } + + +class SummarizeBenchmarkResultsTest(unittest.TestCase): + def test_report_canonicalizes_pre_rename_configuration_spellings(self) -> None: + item = report( + { + "passed": True, + "initial_fast_full": {"elapsed_ms": 100}, + "incremental": {"elapsed_ms": 10}, + "fresh_fast_full_after_change": {"elapsed_ms": 90}, + "canonical_graph": {"equal": True}, + "graph_gate": {"passed": True}, + "oracles": {"passed": True}, + } + ) + item["parameters"] = { + "config_profile": "incremental_semantic_freshness_eager", + "config_overrides": { + "incremental_derived_refresh": "eager", + "incremental_reindex": "off", + "rank_refresh": "stale_on_incremental", + }, + } + + row = SUMMARY.summarize_group("eager-derived-freshness", [item]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["candidate"], "derived-results-refresh-at-publish") + self.assertIn( + "incremental_derived_results_refresh=at_publish", row["capabilities"] + ) + self.assertIn( + "incremental_derived_results_refresh_at_publish", row["capabilities"] + ) + self.assertIn("incremental_reindex=full_rebuild", row["capabilities"]) + self.assertIn( + "rank_refresh=defer_all_incremental_reindexes", row["capabilities"] + ) + self.assertNotIn("incremental_derived_refresh=eager", row["capabilities"]) + self.assertNotIn("incremental_semantic_freshness_eager", row["capabilities"]) + self.assertIn( + "recorded configuration spellings used before the canonical rename", + markdown, + ) + + def test_config_canonicalization_rejects_conflicting_old_and_new_values( + self, + ) -> None: + with self.assertRaisesRegex( + ValueError, "conflicting retained config values after canonicalization" + ): + SUMMARY.canonical_config_overrides( + { + "incremental_derived_refresh": "eager", + "incremental_derived_results_refresh": ( + "defer_all_incremental_reindexes" + ), + } + ) + + def test_config_canonicalization_deduplicates_equivalent_old_and_new_values( + self, + ) -> None: + self.assertEqual( + SUMMARY.canonical_config_overrides( + { + "incremental_derived_refresh": "eager", + "incremental_derived_results_refresh": "at_publish", + } + ), + {"incremental_derived_results_refresh": "at_publish"}, + ) + + def test_query_summary_separates_cold_default_from_repeated_json_latency( + self, + ) -> None: + item = report( + { + "passed": True, + "initial_fast_full": {"elapsed_ms": 100}, + "incremental": {"elapsed_ms": 10}, + "fresh_fast_full_after_change": {"elapsed_ms": 90}, + "canonical_graph": {"equal": True}, + "graph_gate": {"passed": True}, + "oracles": { + "probe": { + "elapsed_ms": 120.0, + "repeated_json_latency_ms": { + "count": 3, + "min": 2.0, + "median": 3.0, + "max": 90.0, + }, + "response_bytes": 80, + }, + "quality": { + "applicable_count": 1, + "passed_count": 1, + "score": 1.0, + }, + "passed": True, + }, + } + ) + + row = SUMMARY.summarize_group("candidate", [item]) + + self.assertEqual(row["cold_query_latency_p50_ms"], 120.0) + self.assertEqual(row["query_latency_p50_ms"], 3.0) + + def test_semantic_pair_lifecycle_reports_expected_deferred_freshness_without_failure( + self, + ) -> None: + case = { + "scenario": "similarity_quality", + "passed": True, + "quality_target_met": False, + "fixture": { + "capability": "similarity", + "relationship": "SIMILAR_TO", + "task_set_sha256": "c" * 64, + }, + "background_repository": {"revision": "d" * 40, "tree": "e" * 40}, + "pair_lifecycle": { + "initial_index": {"elapsed_ms": 31000, "peak_rss_mb": 1400}, + "initial_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + "response_quality": {"elapsed_ms": 600, "response_bytes": 181}, + }, + "mutation": { + "description": "swap clone", + "changed_paths": ["fixture.go"], + }, + "incremental_index": { + "elapsed_ms": 588, + "indexed_work_elapsed_ms": 382, + "peak_rss_mb": 75, + "publish_kind": "incremental_exact", + }, + "incremental_oracles": { + "passed": False, + "pair_classification": { + "confusion": {"tp": 0, "tn": 2, "fp": 0, "fn": 1}, + "f1": None, + }, + "response_quality": {"elapsed_ms": 540, "response_bytes": 190}, + }, + "fresh_index": {"elapsed_ms": 29600, "peak_rss_mb": 1584}, + "fresh_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + "response_quality": {"elapsed_ms": 580, "response_bytes": 181}, + }, + "canonical_graph": {"equal": False}, + "pair_equality": {"passed": False}, + "incremental_policy": { + "policy": "stale_on_incremental", + "immediate_freshness_expected": False, + "immediate_freshness_met": False, + "stale_warning_present": True, + "policy_conformance_met": True, + }, + "policy_conformance_met": True, + }, + } + item = report(case) + item["mode"] = "capability_quality" + item["parameters"] = { + "config_profile": "default", + "config_overrides": {}, + "index_mode": "moderate", + } + + row = SUMMARY.summarize_group("latest-default", [item]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["decision"], "PASS: DEFERRED FRESHNESS") + self.assertEqual(row["incremental_p50_ms"], 588.0) + self.assertEqual(row["full_p50_ms"], 29600.0) + self.assertEqual(row["pair_quality_details"][0]["initial_f1"], 1.0) + self.assertEqual(row["pair_quality_details"][0]["fresh_f1"], 1.0) + self.assertEqual( + row["pair_quality_details"][0]["freshness"], "deferred with warning" + ) + self.assertEqual(row["pair_f1_score"], 1.0) + self.assertIsNone(row["quality_score"]) + self.assertEqual(row["query_observations"], 3) + self.assertIsNone(row["speedup_p50"]) + self.assertIn("intentionally deferred", row["findings"][0]) + self.assertIn("## Semantic pair quality and freshness", markdown) + self.assertIn("deferred with warning", markdown) + + def test_disabled_semantic_pair_control_is_not_described_as_freshness_deferral( + self, + ) -> None: + case = { + "scenario": "similarity_quality", + "passed": True, + "quality_target_met": False, + "fixture": { + "capability": "similarity", + "relationship": "SIMILAR_TO", + "task_set_sha256": "c" * 64, + }, + "oracles": {"passed": False}, + "pair_lifecycle": { + "initial_oracles": { + "passed": False, + "pair_classification": { + "confusion": {"tp": 0, "tn": 2, "fp": 0, "fn": 1}, + "f1": None, + }, + }, + "incremental_oracles": { + "passed": False, + "pair_classification": { + "confusion": {"tp": 0, "tn": 2, "fp": 0, "fn": 1}, + "f1": None, + }, + }, + "fresh_oracles": { + "passed": False, + "pair_classification": { + "confusion": {"tp": 0, "tn": 2, "fp": 0, "fn": 1}, + "f1": None, + }, + }, + "incremental_policy": { + "policy": "stale_on_incremental", + "immediate_freshness_expected": False, + "immediate_freshness_met": False, + "stale_warning_present": True, + "policy_conformance_met": True, + }, + }, + } + item = report(case) + item["mode"] = "capability_quality" + item["parameters"] = { + "config_profile": "similarity_disabled", + "config_overrides": {"similarity_enabled": "false"}, + "index_mode": "moderate", + } + + row = SUMMARY.summarize_group("similarity-disabled", [item]) + + self.assertEqual(row["decision"], "BELOW QUALITY TARGET") + self.assertEqual( + row["pair_quality_details"][0]["freshness"], "capability disabled" + ) + self.assertEqual(row["mutation_details"][0]["canonical"], "capability disabled") + self.assertIn("capability-off control", row["findings"][0]) + self.assertNotIn("initial/fresh pair tasks passed", " ".join(row["findings"])) + + def test_semantic_pair_policy_mismatch_rejects_capability_quality_cell( + self, + ) -> None: + case = { + "scenario": "similarity_quality", + "passed": True, + "quality_target_met": True, + "fixture": { + "capability": "similarity", + "relationship": "SIMILAR_TO", + "task_set_sha256": "c" * 64, + }, + "pair_lifecycle": { + "initial_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + }, + "incremental_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + }, + "fresh_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + }, + "canonical_graph": {"equal": False}, + "incremental_policy": { + "policy": "stale_on_incremental", + "immediate_freshness_expected": False, + "immediate_freshness_met": True, + "stale_warning_present": False, + "policy_conformance_met": False, + }, + }, + } + item = report(case) + item["mode"] = "capability_quality" + item["parameters"] = { + "config_profile": "default", + "config_overrides": {}, + "index_mode": "moderate", + } + + row = SUMMARY.summarize_group("upstream-default", [item]) + + self.assertEqual(row["decision"], "REJECT: freshness policy") + self.assertIn("did not conform", " ".join(row["findings"])) + self.assertNotIn("All applicable", " ".join(row["findings"])) + + def test_initial_semantic_pair_miss_names_stage_and_confusion_counts(self) -> None: + case = { + "scenario": "semantic_edges_quality", + "passed": True, + "quality_target_met": False, + "fixture": {"capability": "semantic_edges"}, + "pair_lifecycle": { + "initial_oracles": { + "passed": False, + "pair_classification": { + "confusion": {"tp": 0, "tn": 2, "fp": 0, "fn": 1}, + "f1": None, + }, + }, + "incremental_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + }, + "fresh_oracles": { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + }, + "incremental_policy": { + "immediate_freshness_expected": True, + "policy_conformance_met": True, + }, + }, + } + item = report(case) + item["mode"] = "capability_quality" + item["parameters"] = { + "config_profile": "incremental_semantic_freshness_eager", + "config_overrides": {"incremental_derived_refresh": "eager"}, + "index_mode": "moderate", + } + + row = SUMMARY.summarize_group("latest-semantic-eager", [item]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["decision"], "BELOW QUALITY TARGET") + finding = " ".join(row["findings"]) + self.assertIn("Initial semantic-pair quality missed", finding) + self.assertIn("TP=0, TN=2, FP=0, FN=1", finding) + self.assertIn("1 expected positive absent", finding) + self.assertNotIn("All applicable", markdown) + + def test_eager_pair_quality_contributes_graph_fidelity_and_overall_score( + self, + ) -> None: + perfect = { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + } + case = { + "scenario": "semantic_edges_quality", + "passed": True, + "quality_target_met": True, + "fixture": {"capability": "semantic_edges"}, + "oracles": {"passed": True}, + "pair_lifecycle": { + "initial_oracles": perfect, + "incremental_oracles": perfect, + "fresh_oracles": perfect, + "canonical_graph": {"equal": True}, + "pair_equality": {"passed": True}, + "incremental_policy": { + "immediate_freshness_expected": True, + "policy_conformance_met": True, + }, + }, + } + item = report(case) + item["mode"] = "capability_quality" + item["parameters"] = { + "config_profile": "incremental_semantic_freshness_eager", + "config_overrides": {"incremental_derived_refresh": "eager"}, + "index_mode": "moderate", + } + + row = SUMMARY.summarize_group("latest-semantic-eager", [item]) + + self.assertEqual(row["decision"], "PASS") + self.assertEqual(row["pair_f1_score"], 1.0) + self.assertEqual(row["graph_fidelity_score"], 1.0) + self.assertEqual(row["task_success_score"], 1.0) + self.assertEqual(row["overall_quality_score"], 1.0) + + def test_pair_lifecycle_canonical_rejection_reports_exact_graph_witness( + self, + ) -> None: + perfect = { + "passed": True, + "pair_classification": { + "confusion": {"tp": 1, "tn": 2, "fp": 0, "fn": 0}, + "f1": 1.0, + }, + } + case = { + "scenario": "semantic_edges_quality", + "passed": False, + "fixture": {"capability": "semantic_edges"}, + "oracles": {"passed": True}, + "pair_lifecycle": { + "initial_oracles": perfect, + "incremental_oracles": perfect, + "fresh_oracles": perfect, + "canonical_graph": { + "equal": False, + "kind": "canonical nodes", + "left_count": 15641, + "right_count": 15641, + "left_only": "'File' 'cbmq_records.py' 'temporary-root.cbmq_records.__file__'", + "right_only": None, + }, + "incremental_policy": { + "immediate_freshness_expected": True, + "policy_conformance_met": True, + }, + }, + } + item = report(case) + item["mode"] = "capability_quality" + item["parameters"] = { + "config_profile": "incremental_semantic_freshness_eager", + "config_overrides": {"incremental_derived_refresh": "eager"}, + "index_mode": "moderate", + } + + row = SUMMARY.summarize_group("upstream-semantic-eager", [item]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["decision"], "REJECT: graph correctness") + finding = " ".join(row["findings"]) + self.assertIn( + "canonical nodes mismatch (incremental=15641, fresh=15641)", finding + ) + self.assertIn("cbmq_records.py", finding) + self.assertNotIn("no stage-level witness was recorded", markdown) + + def test_composition_spec_groups_validated_experiment_cells(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + experiment_root = root / "experiment" + experiment_root.mkdir() + plan = experiment_root / "immutable-plan.json" + plan.write_text( + json.dumps( + { + "schema_version": 1, + "cells": [{"label": "rank"}, {"label": "incremental"}], + } + ), + encoding="utf-8", + ) + for label in ("rank", "incremental"): + (experiment_root / f"{label}.json").write_text( + json.dumps({"binary_metadata": {"sha256": "a" * 64}, "cases": []}), + encoding="utf-8", + ) + composition = root / "composition.json" + composition.write_text( + json.dumps( + { + "schema_version": 1, + "experiments": { + "fixture": { + "plan": "experiment/immutable-plan.json", + "experiment_root": "experiment", + } + }, + "groups": [ + { + "label": "latest-default-mcp", + "inputs": [ + { + "experiment": "fixture", + "cell_labels": ["rank", "incremental"], + } + ], + } + ], + } + ), + encoding="utf-8", + ) + + class FakeExperiment: + @staticmethod + def expand_matrix_spec(spec: dict) -> dict: + raise AssertionError( + "report composition must not re-expand a matrix" + ) + + @staticmethod + def validate_plan(document: dict) -> list[dict]: + return document["cells"] + + @staticmethod + def completed_report_inputs( + path: Path, cells: list[dict] + ) -> list[tuple[str, Path]]: + return [ + (cell["label"], path / f"{cell['label']}.json") + for cell in cells + ] + + grouped, provenance = SUMMARY.load_composition_groups( + composition, FakeExperiment + ) + + self.assertEqual(len(grouped["latest-default-mcp"]), 2) + self.assertEqual(provenance["input_count"], 2) + self.assertEqual(provenance["spec_path"], str(composition.resolve())) + + legacy_document = json.loads(composition.read_text(encoding="utf-8")) + legacy_document["campaigns"] = legacy_document.pop("experiments") + legacy_entry = legacy_document["campaigns"]["fixture"] + legacy_entry["campaign_root"] = legacy_entry.pop("experiment_root") + legacy_source = legacy_document["groups"][0]["inputs"][0] + legacy_source["campaign"] = legacy_source.pop("experiment") + composition.write_text(json.dumps(legacy_document), encoding="utf-8") + + legacy_grouped, legacy_provenance = SUMMARY.load_composition_groups( + composition, FakeExperiment + ) + self.assertEqual(len(legacy_grouped["latest-default-mcp"]), 2) + self.assertIn("experiments", legacy_provenance) + self.assertNotIn("campaigns", legacy_provenance) + + def test_search_projection_report_keeps_identity_quality_beside_size(self) -> None: + document = { + "mode": "search_projection", + "run_id": "projection-example", + "binary_metadata": {"sha256": "b" * 64}, + "cleanup": {"removed": True}, + "completion": {"status": "complete", "exit_code": 0}, + "observations": [ + { + "variant": "compact_true", + "returned_count": 30, + "identity_equal_to_default": True, + "property_fields": [], + "internal_fields": [], + "response_bytes": 6824, + "response_token_estimate": 1706, + "elapsed_ms": 10.0, + "post_call_rss_kb": 13696, + "transport_survived": True, + "server_reaped": True, + }, + { + "variant": "compact_false", + "returned_count": 30, + "identity_equal_to_default": True, + "property_fields": ["complexity", "signature"], + "internal_fields": [], + "response_bytes": 18374, + "response_token_estimate": 4594, + "elapsed_ms": 8.0, + "post_call_rss_kb": 14000, + "transport_survived": True, + "server_reaped": True, + }, + ], + "derived": { + "passed": True, + "identity_parity": True, + "internal_fields_absent": True, + "compact_bytes": 6824, + "non_compact_bytes": 18374, + "non_compact_over_compact_ratio": 2.693, + "claim_boundary": "One observation per variant.", + }, + } + + markdown = SUMMARY.render_search_projection(document) + + self.assertIn("Outcome: complete", markdown) + self.assertIn("compact true | 30 | Equal | none | 6,824 | 1,706", markdown) + self.assertIn( + "non-compact | 30 | Equal | complexity, signature | 18,374", markdown + ) + self.assertIn("62.9% fewer payload bytes", markdown) + self.assertIn("No fp, sp, or bt", markdown) + self.assertIn("not a latency comparison", markdown) + self.assertNotIn("FAIL", markdown) + + def test_search_projection_rejects_wrong_document(self) -> None: + with self.assertRaisesRegex(ValueError, "expected a search_projection"): + SUMMARY.render_search_projection({"mode": "incremental"}) + + def test_list_projects_scaling_report_separates_size_latency_and_lifecycle( + self, + ) -> None: + document = { + "mode": "list_projects_scaling", + "run_id": "list-projects-example", + "binary_metadata": {"sha256": "a" * 64}, + "cleanup": {"requested": True, "removed": True}, + "completion": {"status": "complete", "exit_code": 0}, + "parameters": { + "token_estimator": "utf8_bytes_div_4_ceil", + "rss_measurement": "post_call_resident_kb_not_peak", + }, + "observations": [ + { + "requested_projects": 1, + "returned_projects": 1, + "response_bytes": 284, + "response_token_estimate": 71, + "elapsed_ms": 15.149, + "post_call_rss_kb": 16576, + "transport_survived": True, + "server_reaped": True, + "fixture_db_bytes": 6160384, + "passed": True, + }, + { + "requested_projects": 64, + "returned_projects": 64, + "response_bytes": 12695, + "response_token_estimate": 3174, + "elapsed_ms": 82.118, + "post_call_rss_kb": 12784, + "transport_survived": True, + "server_reaped": True, + "fixture_db_bytes": 394264576, + "passed": True, + }, + ], + "derived": { + "passed": True, + "incremental_response_bytes_per_project": 197.0, + "claim_boundary": "Measures list_projects alone; not combined calls.", + }, + } + + markdown = SUMMARY.render_list_projects_scaling(document) + + self.assertIn("Outcome: complete", markdown) + self.assertIn("64 | 64 | 12,695 | 3,174 | 82.118", markdown) + self.assertIn("Survived | Reaped", markdown) + self.assertIn("197.0 bytes per added project", markdown) + self.assertIn("one observation per project count", markdown) + self.assertIn("not peak RSS", markdown) + self.assertNotIn("FAIL", markdown) + + def test_list_projects_scaling_rejects_wrong_document(self) -> None: + with self.assertRaisesRegex(ValueError, "expected a list_projects_scaling"): + SUMMARY.render_list_projects_scaling({"mode": "incremental"}) + + def test_mcp_surface_report_keeps_discovery_dispatch_and_behavior_distinct( + self, + ) -> None: + def surface(count: int, size: int, tokens: int, elapsed: float) -> dict: + return { + "tool_count": count, + "response_bytes": size, + "response_token_estimate": tokens, + "list_elapsed_ms": elapsed, + } + + document = { + "mode": "mcp_surface_parity", + "surfaces": { + "classic": surface(16, 21000, 5250, 0.4), + "streamlined_pre_reveal": surface(6, 13000, 3250, 0.3), + "streamlined_post_reveal": surface(18, 24000, 6000, 0.2), + }, + "comparison": { + "pre_reveal": { + "advertised_classic_tools": "4/16", + "dispatch_recognized_classic_tools": "16/16", + "intentionally_hidden_classic_tools": ["index_repository"], + "get_code_alias": { + "property_names_equal": True, + "required_names_equal": True, + "validation_shape_equal": True, + "schema_equal": False, + }, + }, + "post_reveal": { + "classic_name_parity": True, + "classic_schema_parity": True, + "classic_contract_parity": True, + "tools_list_changed_observed": True, + }, + "capability_parity": [ + { + "capability": "programmable_graph_analysis", + "outcome": "problem-specific read-only Cypher", + "classic_advertised": True, + "streamlined_pre_reveal_advertised": True, + "streamlined_pre_reveal_callable": True, + "streamlined_post_reveal_advertised": True, + } + ], + "lifecycle_passed": True, + }, + } + + markdown = SUMMARY.render_mcp_surface_parity(document) + + self.assertLess( + markdown.index("Capability outcome"), markdown.index("Advertised tools") + ) + self.assertIn("problem-specific read-only Cypher", markdown) + self.assertIn("Pure classic | 16 | 16/16", markdown) + self.assertIn("Streamlined before reveal | 6 | 4/16 | 16/16", markdown) + self.assertIn("Same streamlined process after reveal | 18 | 16/16", markdown) + self.assertIn("MCP processes and reader threads reaped: true", markdown) + self.assertIn("does not prove successful execution", markdown) + self.assertIn("Behavioral parity requires capability fixtures", markdown) + self.assertIn("validation shape equal=true", markdown) + self.assertIn("complete advertised schema identical=false", markdown) + + def test_mcp_surface_report_rejects_regular_benchmark_document(self) -> None: + with self.assertRaisesRegex(ValueError, "expected an mcp_surface_parity"): + SUMMARY.render_mcp_surface_parity({"mode": "incremental"}) + + def test_rank_beyond_cutoff_is_not_reported_as_missing(self) -> None: + case = { + "passed": False, + "oracles": { + "passed": False, + "quality": {"passed": False, "passed_count": 0, "applicable_count": 1}, + "central_order_search": { + "quality": { + "applicable": True, + "passed": False, + "criterion": "central result appears by rank five", + "expected_substring": "zz_order_core", + "rank": 9, + "returned_count": 9, + "reciprocal_rank": 1 / 9, + "hit_at_1": False, + "hit_at_5": False, + "ndcg_at_5": 0.0, + } + }, + }, + } + + details = SUMMARY.quality_oracle_details([case]) + + self.assertEqual(details[0]["result"], "BELOW CUTOFF (rank 9 of 9)") + + def test_capability_quality_shortfall_is_not_called_correctness_failure( + self, + ) -> None: + item = report( + { + "scenario": "rank_quality", + "passed": True, + "execution_passed": True, + "quality_target_met": False, + "oracles": { + "passed": False, + "quality": { + "passed": False, + "passed_count": 0, + "applicable_count": 1, + "score": 1 / 9, + }, + "central_order_search": { + "quality": { + "applicable": True, + "passed": False, + "expected_substring": "zz_order_core", + "rank": 9, + "returned_count": 9, + "relevance_cutoff": 5, + } + }, + }, + } + ) + item["mode"] = "capability_quality" + + row = SUMMARY.summarize_group("rank-disabled", [item]) + + self.assertEqual(row["decision"], "BELOW QUALITY TARGET") + self.assertIn("below quality cutoff (rank 9, cutoff 5)", row["findings"][0]) + self.assertNotIn("failed", row["findings"][0]) + + def test_report_aggregates_graded_ndcg_without_hiding_mrr(self) -> None: + case = { + "scenario": "rank_quality", + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "passed": True, + "quality": { + "passed": True, + "passed_count": 1, + "applicable_count": 1, + "score": 0.5, + "hit_at_1": 0.0, + "hit_at_5": 1.0, + "mean_ndcg_at_5": 0.8, + "ndcg_applicable_count": 1, + }, + "ranked_probe": { + "quality": { + "applicable": True, + "passed": True, + "criterion": "graded architectural relevance", + "expected_substring": "entry_point", + "rank": 2, + "returned_count": 5, + "reciprocal_rank": 0.5, + "hit_at_1": False, + "hit_at_5": True, + "relevance_judgments": 3, + "ndcg_at_5": 0.8, + } + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100}, + } + + row = SUMMARY.summarize_group("rank-on", [report(case)]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["quality_score"], 0.5) + self.assertEqual(row["ndcg_at_5"], 0.8) + self.assertIn("nDCG@5", markdown) + self.assertIn("0.800", markdown) + self.assertIn("3 judgments", markdown) + self.assertIn("doi.org/10.1145/582415.582418", markdown) + + def test_fast_mode_report_marks_similarity_and_semantic_quality_not_applicable( + self, + ) -> None: + case = { + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": {"elapsed_ms": 10}, + "fresh_fast_full_after_change": {"elapsed_ms": 100}, + } + item = report(case) + item["parameters"].update( + { + "index_mode": "fast", + "capability_applicability": { + "rank": {"applicable": True, "reason": "available in fast mode"}, + "similarity": { + "applicable": False, + "reason": "SIMILAR_TO generation requires full or moderate mode", + }, + "semantic_edges": { + "applicable": False, + "reason": "SEMANTICALLY_RELATED generation requires full or moderate mode", + }, + }, + } + ) + + row = SUMMARY.summarize_group("fast", [item]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["index_modes"], "fast") + self.assertEqual( + row["capability_applicability"]["similarity"], + "N/A: SIMILAR_TO generation requires full or moderate mode", + ) + self.assertIn("## Algorithm-quality applicability", markdown) + self.assertIn( + "N/A: SIMILAR_TO generation requires full or moderate mode", markdown + ) + + def test_candidate_support_overrides_mode_based_applicability(self) -> None: + item = report({"passed": True}) + item["parameters"].update( + { + "index_mode": "fast", + "capability_applicability": { + "rank": {"applicable": True, "reason": "available in fast mode"}, + "dependencies": { + "applicable": True, + "reason": "available in fast mode", + }, + }, + "capability_support": {"rank": False, "dependencies": False}, + } + ) + + row = SUMMARY.summarize_group("upstream", [item]) + + self.assertEqual( + row["capability_applicability"]["rank"], "unsupported by candidate" + ) + self.assertEqual( + row["capability_applicability"]["dependencies"], "unsupported by candidate" + ) + self.assertEqual(row["dependency_mode"], "unsupported") + + def test_dependency_breakdown_reads_new_and_retained_result_shapes(self) -> None: + new_case = { + "passed": True, + "canonical_graph": {"equal": True}, + "initial_fast_full": { + "dependency_indexing": { + "measurement_status": "measured", + "phase_elapsed_ms": 120, + "packages_indexed": 6, + } + }, + "incremental": { + "elapsed_ms": 10, + "measurement_log_markers": [ + "level=info msg=prof phase=index_repository " + "sub=dep_auto_index ms=4 us=4000" + ], + "response": {"dependencies_indexed": 2}, + }, + "fresh_fast_full_after_change": { + "elapsed_ms": 100, + "measurement_log_markers": [ + "level=info msg=prof phase=index_repository " + "sub=dep_auto_index ms=80 us=80000" + ], + "response": {"dependencies_indexed": 6}, + }, + } + item = report(new_case) + item["parameters"]["config_profile"] = "default" + item["parameters"]["config_overrides"] = {} + + row = SUMMARY.summarize_group("latest-default", [item]) + + self.assertEqual(row["dependency_mode"], "enabled (observed)") + self.assertEqual(row["dependency_packages_p50"], 6.0) + self.assertEqual(row["dependency_initial_p50_ms"], 120.0) + self.assertEqual(row["dependency_incremental_p50_ms"], 4.0) + self.assertEqual(row["dependency_fresh_p50_ms"], 80.0) + + def test_capability_quality_reports_initial_full_time_and_peak_rss(self) -> None: + case = { + "passed": True, + "quality_target_met": True, + "initial_fast_full": {"elapsed_ms": 125, "peak_rss_mb": 42}, + } + item = report(case) + item["mode"] = "capability_quality" + + row = SUMMARY.summarize_group("quality", [item]) + + self.assertEqual(row["full_p50_ms"], 125.0) + self.assertEqual(row["full_observations"], 1) + self.assertEqual(row["peak_rss_mb"], 42) + + def test_dependency_mode_distinguishes_disabled_unsupported_and_unknown( + self, + ) -> None: + case = { + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": {"elapsed_ms": 10}, + "fresh_fast_full_after_change": {"elapsed_ms": 100}, + } + disabled = report(case) + disabled["parameters"]["config_profile"] = "dependency_disabled" + disabled["parameters"]["config_overrides"] = {"auto_index_deps": "false"} + unsupported = report(case) + unsupported["parameters"]["capability_support"] = {"auto_index_deps": False} + unknown = report(case) + unknown["parameters"]["config_overrides"] = {} + + self.assertEqual( + SUMMARY.summarize_group("disabled", [disabled])["dependency_mode"], + "disabled (explicit)", + ) + self.assertEqual( + SUMMARY.summarize_group("upstream", [unsupported])["dependency_mode"], + "unsupported", + ) + self.assertEqual( + SUMMARY.summarize_group("historical", [unknown])["dependency_mode"], + "unknown", + ) + + def test_markdown_reports_dependency_cost_and_methodology_sources(self) -> None: + case = { + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": { + "elapsed_ms": 10, + "dependency_indexing": { + "measurement_status": "measured", + "phase_elapsed_ms": 3, + "packages_indexed": 1, + }, + }, + "fresh_fast_full_after_change": {"elapsed_ms": 100}, + } + item = report(case) + item["parameters"]["config_overrides"] = {"auto_index_deps": "true"} + + markdown = SUMMARY.render_markdown([SUMMARY.summarize_group("deps-on", [item])]) + + self.assertIn("## Dependency-indexing capability and cost", markdown) + self.assertIn("enabled (explicit)", markdown) + self.assertIn("trec.nist.gov/data/qa.html", markdown) + self.assertIn("doi.org/10.1145/582415.582418", markdown) + self.assertIn("arxiv.org/abs/2007.10899", markdown) + self.assertIn("doi.org/10.1109/4235.996017", markdown) + self.assertIn("confidence interval", markdown) + + def test_quality_failure_blocks_acceptance_even_with_high_speedup(self) -> None: + case = { + "passed": False, + "canonical_graph": { + "equal": False, + "kind": "canonical nodes", + "left_count": 10, + "right_count": 11, + "left_only": "Function\told_value", + "right_only": "Function\tnew_value", + }, + "oracles": { + "passed": True, + "route": { + "quality": { + "passed": False, + "expected_substring": "/api/pan4-oracle", + } + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + "speedup_full_rebuild_over_incremental": 10.0, + } + row = SUMMARY.summarize_group("latest-rank-off", [report(case)]) + self.assertEqual(row["decision"], "REJECT: graph correctness") + self.assertEqual(row["canonical"], "0/1") + self.assertEqual(row["speedup_p50"], 10.0) + self.assertEqual( + row["findings"], + [ + "canonical nodes mismatch (incremental=10, fresh=11); " + "witness: Function old_value vs Function new_value", + "route failed (expected /api/pan4-oracle)", + ], + ) + markdown = SUMMARY.render_markdown([row]) + self.assertIn("Graph error", markdown) + self.assertIn("Result / quality error", markdown) + self.assertIn("Run / lifecycle error", markdown) + self.assertIn("**GRAPH ERROR**", markdown) + self.assertIn("**RESULT ERROR**", markdown) + + def test_declared_stale_derived_views_are_not_core_correctness_failure( + self, + ) -> None: + case = { + "passed": True, + "canonical_graph": { + "equal": False, + "kind": "canonical edges", + "left_count": 100, + "right_count": 90, + "left_only": "SEMANTICALLY_RELATED stale row", + }, + "freshness_scoped_graph": { + "equal": True, + "declared_stale_views": ["semantic_edges"], + "excluded_edge_types": ["SEMANTICALLY_RELATED"], + }, + "graph_gate": { + "passed": True, + "policy": "declared_stale_derived_views", + "canonical_equal": False, + "freshness_scoped_equal": True, + "declared_stale_views": ["semantic_edges"], + }, + "oracles": { + "passed": True, + "quality": { + "applicable_count": 1, + "passed_count": 1, + "score": 1.0, + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + } + + row = SUMMARY.summarize_group("latest-default", [report(case)]) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["decision"], "PASS: DECLARED STALE VIEWS") + self.assertEqual(row["core_graph_fidelity_score"], 1.0) + self.assertEqual(row["graph_fidelity_score"], 0.0) + self.assertIn("declared stale derived views", " ".join(row["findings"])) + self.assertIn("Core graph", markdown) + self.assertIn("Full graph freshness", markdown) + + def test_exact_canonical_graph_is_not_labeled_declared_stale(self) -> None: + case = { + "passed": True, + "canonical_graph": {"equal": True}, + # Retained results produced before canonical-equality precedence may + # still contain the older scoped gate. Reporting must use the + # stronger exact comparison without rewriting immutable evidence. + "graph_gate": { + "passed": True, + "policy": "declared_stale_derived_views", + "canonical_equal": True, + "freshness_scoped_equal": True, + "declared_stale_views": ["semantic_edges"], + }, + "oracles": { + "passed": True, + "quality": { + "applicable_count": 1, + "passed_count": 1, + "score": 1.0, + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + } + + row = SUMMARY.summarize_group("latest-disabled", [report(case)]) + + self.assertEqual(row["decision"], "PASS") + self.assertEqual(row["canonical"], "1/1") + self.assertNotIn("declared stale derived views", " ".join(row["findings"])) + + def test_aggregate_reports_p50_p95_peak_rss_and_cleanup(self) -> None: + reports = [] + for elapsed, speedup, peak in ((10, 10.0, 90), (20, 5.0, 110), (30, 3.0, 100)): + case = { + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": {"passed": True}, + "incremental": {"elapsed_ms": elapsed, "peak_rss_mb": peak}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 120}, + "speedup_full_rebuild_over_incremental": speedup, + } + reports.append(report(case)) + row = SUMMARY.summarize_group("latest-rank-off", reports) + self.assertEqual(row["incremental_p50_ms"], 20.0) + self.assertEqual(row["incremental_p95_ms"], 30.0) + self.assertEqual(row["peak_rss_mb"], 120) + self.assertEqual(row["lifecycle"], "disposed 3/3") + self.assertEqual(row["decision"], "PASS") + + def test_lifecycle_distinguishes_retained_evidence_from_cleanup_failure( + self, + ) -> None: + case = {"passed": True} + retained = report(case) + retained["cleanup"] = {"requested": False, "removed": False} + failed = report(case) + failed["cleanup"] = {"requested": True, "removed": False} + + retained_row = SUMMARY.summarize_group("retained", [retained]) + failed_row = SUMMARY.summarize_group("failed", [failed]) + + self.assertEqual(retained_row["lifecycle"], "retained by request 1/1") + self.assertEqual(failed_row["lifecycle"], "CLEANUP FAILED 1/1") + self.assertEqual(failed_row["decision"], "REJECT: lifecycle cleanup") + markdown = SUMMARY.render_markdown([retained_row, failed_row]) + self.assertIn("Evidence lifecycle", markdown) + self.assertIn("**CLEANUP ERROR**", markdown) + self.assertNotIn("Cleanup |", markdown) + + def test_markdown_places_quality_before_performance(self) -> None: + case = { + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + "speedup_full_rebuild_over_incremental": 10.0, + } + markdown = SUMMARY.render_markdown( + [SUMMARY.summarize_group("latest", [report(case)])] + ) + self.assertLess(markdown.index("Decision"), markdown.index("Speedup p50")) + self.assertIn("Binary SHA-256", markdown) + self.assertIn("Correctness and quality findings", markdown) + self.assertIn("exact default tool-response payload", markdown) + self.assertIn("consult Cases and the immutable experiment manifest", markdown) + + def test_query_quality_size_latency_and_pareto_frontier(self) -> None: + compact_case = { + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "quality": { + "passed": True, + "passed_count": 2, + "applicable_count": 2, + "score": 0.75, + "hit_at_1": 0.5, + "hit_at_5": 1.0, + }, + "marker": { + "elapsed_ms": 5, + "response_bytes": 80, + "response_token_estimate": 20, + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + "speedup_full_rebuild_over_incremental": 10.0, + } + slower_case = { + **compact_case, + "oracles": { + "quality": { + "passed": True, + "passed_count": 2, + "applicable_count": 2, + "score": 0.75, + "hit_at_1": 0.5, + "hit_at_5": 1.0, + }, + "marker": { + "elapsed_ms": 8, + "response_bytes": 120, + "response_token_estimate": 30, + }, + }, + "incremental": {"elapsed_ms": 20, "peak_rss_mb": 100}, + } + rows = [ + SUMMARY.summarize_group("compact", [report(compact_case)]), + SUMMARY.summarize_group("slower", [report(slower_case)]), + ] + SUMMARY.mark_pareto_frontier(rows) + self.assertEqual(rows[0]["quality_score"], 0.75) + self.assertAlmostEqual(rows[0]["overall_quality_score"], 0.75 ** (1 / 3)) + self.assertEqual(rows[0]["graph_fidelity_score"], 1.0) + self.assertEqual(rows[0]["task_success_score"], 1.0) + self.assertEqual(rows[0]["hit_at_1"], 0.5) + self.assertEqual(rows[0]["hit_at_5"], 1.0) + self.assertEqual(rows[0]["query_response_p50_bytes"], 80.0) + self.assertEqual(rows[0]["query_response_p50_tokens"], 20.0) + self.assertEqual(rows[0]["query_latency_p50_ms"], 5.0) + self.assertEqual(rows[0]["pareto"], "frontier") + self.assertEqual(rows[1]["pareto"], "dominated by compact") + + def test_pareto_does_not_compare_different_workloads(self) -> None: + repository_case = { + "scenario": "c_new_leaf", + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "quality": { + "passed": True, + "passed_count": 1, + "applicable_count": 1, + "score": 1.0, + }, + "marker": { + "elapsed_ms": 300, + "response_bytes": 400, + "response_token_estimate": 100, + }, + }, + "incremental": {"elapsed_ms": 700, "peak_rss_mb": 1500}, + "fresh_fast_full_after_change": {"elapsed_ms": 30000, "peak_rss_mb": 1500}, + } + canary_case = { + **repository_case, + "scenario": "semantic_edges_quality", + "oracles": { + "quality": { + "passed": True, + "passed_count": 1, + "applicable_count": 1, + "score": 1.0, + }, + "marker": { + "elapsed_ms": 10, + "response_bytes": 200, + "response_token_estimate": 50, + }, + }, + "incremental": {"elapsed_ms": 50, "peak_rss_mb": 75}, + "fresh_fast_full_after_change": {"elapsed_ms": 200, "peak_rss_mb": 75}, + } + rows = [ + SUMMARY.summarize_group("repository", [report(repository_case)]), + SUMMARY.summarize_group("canary", [report(canary_case)]), + ] + + SUMMARY.mark_pareto_frontier(rows) + + self.assertEqual(rows[0]["pareto"], "frontier") + self.assertEqual(rows[1]["pareto"], "frontier") + self.assertIn("same workload", rows[0]["pareto_reason"]) + + def test_markdown_names_oracles_and_explains_quality_categories(self) -> None: + case = { + "scenario": "route_handler", + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "passed": True, + "quality": { + "passed": True, + "passed_count": 1, + "applicable_count": 1, + "score": 0.5, + "hit_at_1": 0.0, + "hit_at_5": 1.0, + }, + "route_freshness_probe": { + "elapsed_ms": 3, + "response_bytes": 20, + "response_token_estimate": 5, + "quality": { + "applicable": True, + "passed": True, + "criterion": "new route literal appears in route search", + "expected_substring": "/api/pan4-oracle", + "rank": 2, + "returned_count": 5, + "reciprocal_rank": 0.5, + "hit_at_1": False, + "hit_at_5": True, + }, + }, + "not_applicable_probe": { + "quality": { + "applicable": False, + "passed": None, + "criterion": "not applicable to this mutation", + } + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + } + markdown = SUMMARY.render_markdown( + [SUMMARY.summarize_group("rank-off", [report(case)])] + ) + self.assertIn("Overall quality", markdown) + self.assertIn("Retrieval MRR", markdown) + self.assertIn("Graph fidelity", markdown) + self.assertIn("Task success", markdown) + self.assertIn("route_freshness_probe", markdown) + self.assertIn("new route literal appears in route search", markdown) + self.assertIn("PASS (rank 2 of 5)", markdown) + self.assertIn("N/A", markdown) + self.assertIn("geometric mean", markdown) + self.assertIn("trec.nist.gov", markdown) + self.assertIn("rank_disabled", markdown) + + def test_partial_probe_success_remains_visible_beside_hard_rejection(self) -> None: + case = { + "passed": False, + "canonical_graph": {"equal": True}, + "oracles": { + "passed": False, + "quality": { + "passed": False, + "passed_count": 4, + "applicable_count": 5, + "score": 0.7, + "hit_at_1": 0.6, + "hit_at_5": 0.8, + }, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + "fresh_fast_full_after_change": {"elapsed_ms": 100, "peak_rss_mb": 90}, + } + row = SUMMARY.summarize_group("partial", [report(case)]) + self.assertEqual(row["decision"], "REJECT: task correctness") + self.assertEqual(row["task_success_score"], 0.8) + self.assertAlmostEqual( + row["overall_quality_score"], (0.7 * 1.0 * 0.8) ** (1 / 3) + ) + markdown = SUMMARY.render_markdown([row]) + self.assertIn("0.800", markdown) + self.assertIn("4/5 / 1/1 / 1/1 / 0/1", markdown) + + def test_composed_disabled_capability_is_below_target_not_correctness_rejection( + self, + ) -> None: + quality_report = report( + { + "passed": False, + "execution_passed": True, + "quality_target_met": False, + "fixture": {"capability": "rank"}, + "oracles": { + "passed": False, + "quality": { + "passed": False, + "passed_count": 0, + "applicable_count": 1, + "score": 0.1, + }, + }, + } + ) + quality_report["mode"] = "capability_quality" + incremental_report = report( + { + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 80}, + } + ) + incremental_report["mode"] = "matrix" + + row = SUMMARY.summarize_group( + "rank-disabled", [quality_report, incremental_report] + ) + + self.assertEqual(row["decision"], "BELOW QUALITY TARGET") + + def test_observation_ranges_report_dispersion_without_claiming_confidence( + self, + ) -> None: + reports = [] + for incremental_ms, query_ms, full_ms in ( + (8, 2, 80), + (10, 3, 100), + (20, 7, 140), + ): + reports.append( + report( + { + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "passed": True, + "probe": { + "elapsed_ms": query_ms, + "response_bytes": 40, + "response_token_estimate": 10, + }, + }, + "incremental": { + "elapsed_ms": incremental_ms, + "peak_rss_mb": 80, + }, + "fresh_fast_full_after_change": {"elapsed_ms": full_ms}, + } + ) + ) + + row = SUMMARY.summarize_group("latest", reports) + markdown = SUMMARY.render_markdown([row]) + + self.assertEqual(row["incremental_range_ms"], (8.0, 20.0)) + self.assertEqual(row["query_range_ms"], (2.0, 7.0)) + self.assertEqual(row["full_range_ms"], (80.0, 140.0)) + self.assertIn("## Observation ranges", markdown) + self.assertIn("[8.0, 20.0]", markdown) + self.assertIn("descriptive min–max ranges, not confidence intervals", markdown) + + def test_pareto_reason_lists_missing_axes_for_ineligible_row(self) -> None: + row = SUMMARY.summarize_group( + "incomplete", + [report({"passed": True, "canonical_graph": {"equal": True}})], + ) + SUMMARY.mark_pareto_frontier([row]) + self.assertEqual(row["pareto"], "ineligible") + self.assertIn("missing", row["pareto_reason"]) + self.assertIn("incremental_p50_ms", row["pareto_reason"]) + + def test_frontier_crossover_pairs_nearest_fallback_and_exact_caps(self) -> None: + reports = [] + for cap, contract, elapsed, work, peak in ( + (16, "configured_cap_fallback", 100, 20, 80), + (32, "exact_frontier", 200, 120, 90), + (64, "exact_frontier", 210, 130, 95), + ): + case = { + "scenario": "go_inbound_frontier", + "passed": True, + "canonical_graph": {"equal": True}, + "frontier_coverage_gate": {"contract": contract, "passed": True}, + "incremental": { + "elapsed_ms": elapsed, + "indexed_work_elapsed_ms": work, + "peak_rss_mb": peak, + }, + "fresh_fast_full_after_change": {"elapsed_ms": 400}, + } + item = report(case) + item["parameters"]["frontier_files"] = 16 + item["parameters"]["config_overrides"][ + "incremental_exact_max_affected_paths" + ] = str(cap) + reports.append((f"cap-{cap}", item)) + + rows = [SUMMARY.summarize_group(label, [item]) for label, item in reports] + crossovers = SUMMARY.frontier_crossover_rows(rows) + + self.assertEqual(len(crossovers), 1) + self.assertEqual(crossovers[0]["fallback_cap"], 16) + self.assertEqual(crossovers[0]["exact_cap"], 32) + self.assertEqual(crossovers[0]["affected_files"], 17) + self.assertEqual(crossovers[0]["exact_fallback_ratio"], 2.0) + self.assertEqual(crossovers[0]["conclusion"], "fallback faster") + markdown = SUMMARY.render_markdown(rows) + self.assertIn("## Exact-frontier cap crossover", markdown) + self.assertIn( + "| go_inbound_frontier | 17 | 16 | 100.0 | 20.0 | 80.0 | 32 | 200.0", + markdown, + ) + self.assertIn("2.00×", markdown) + self.assertIn("fallback faster", markdown) + + def test_markdown_breaks_out_source_mutation_and_reindex_phases(self) -> None: + case = { + "scenario": "route_handler", + "mutation": { + "description": "HTTP handler source edit with route literal oracle", + "changed_paths": ["src/ui/http_server.c"], + }, + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": { + "elapsed_ms": 120, + "indexed_work_elapsed_ms": 45, + "publish_kind": "incremental_exact", + "exact_reason": None, + }, + "fresh_fast_full_after_change": {"elapsed_ms": 600}, + "speedup_full_rebuild_over_incremental": 5.0, + } + markdown = SUMMARY.render_markdown( + [SUMMARY.summarize_group("latest", [report(case)])] + ) + + self.assertIn("## Incremental mutation and reindex breakdown", markdown) + self.assertIn("HTTP handler source edit with route literal oracle", markdown) + self.assertIn("src/ui/http_server.c", markdown) + self.assertIn("incremental_exact", markdown) + self.assertIn("| 120.0 | 45.0 | 600.0 | 5.00 |", markdown) + self.assertIn("end-to-end", markdown) + self.assertIn("isolates indexing work", markdown) + + def test_markdown_reports_matrix_changed_paths_from_case_root(self) -> None: + case = { + "scenario": "go_inbound_frontier", + "changed_paths": ["leaf.go"], + "scenario_metadata": { + "source": "synthetic_inbound_frontier", + "language": "go", + "incremental_contract": "exact_frontier", + }, + "passed": True, + "canonical_graph": {"equal": True}, + "incremental": { + "elapsed_ms": 59, + "indexed_work_elapsed_ms": 26, + "publish_kind": "incremental_exact", + }, + "fresh_fast_full_after_change": {"elapsed_ms": 63}, + "speedup_full_rebuild_over_incremental": 63 / 59, + } + + markdown = SUMMARY.render_markdown( + [SUMMARY.summarize_group("latest", [report(case)])] + ) + + self.assertIn("synthetic go inbound-frontier definition edit", markdown) + self.assertIn("leaf.go", markdown) + self.assertNotIn( + "| not reported | not reported | incremental_exact |", markdown + ) + + def test_markdown_computes_latest_speedups_for_matching_capabilities(self) -> None: + def measured_case(incremental_ms: int, full_ms: int, query_ms: int) -> dict: + return { + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "quality": { + "passed": True, + "passed_count": 1, + "applicable_count": 1, + }, + "probe": { + "elapsed_ms": query_ms, + "response_token_estimate": 10, + }, + }, + "incremental": {"elapsed_ms": incremental_ms, "peak_rss_mb": 100}, + "fresh_fast_full_after_change": {"elapsed_ms": full_ms}, + } + + rows = [ + SUMMARY.summarize_group( + "baseline-rank-off", [report(measured_case(20, 100, 8))] + ), + SUMMARY.summarize_group( + "latest-rank-off", [report(measured_case(10, 50, 4))] + ), + ] + markdown = SUMMARY.render_markdown(rows) + + self.assertIn("## Quality-constrained cross-version timing", markdown) + self.assertIn( + "| latest-rank-off | baseline-rank-off | 2.00× | 2.00× | 2.00× |", markdown + ) + self.assertIn("descriptive only", markdown) + + def test_cross_version_ratios_are_suppressed_when_quality_decisions_differ( + self, + ) -> None: + case = { + "passed": True, + "canonical_graph": {"equal": True}, + "oracles": { + "passed": True, + "quality": {"passed": True, "passed_count": 1, "applicable_count": 1}, + "probe": {"elapsed_ms": 4, "response_token_estimate": 10}, + }, + "incremental": {"elapsed_ms": 10, "peak_rss_mb": 100}, + "fresh_fast_full_after_change": {"elapsed_ms": 50}, + } + baseline = SUMMARY.summarize_group("baseline-rank-off", [report(case)]) + latest = SUMMARY.summarize_group("latest-rank-off", [report(case)]) + baseline["decision"] = "PASS" + latest["decision"] = "PASS: DEFERRED FRESHNESS" + + comparison = SUMMARY.historical_delta_rows([baseline, latest])[0] + + self.assertIsNone(comparison["incremental_speedup"]) + self.assertIsNone(comparison["full_speedup"]) + self.assertIsNone(comparison["query_speedup"]) + self.assertEqual( + comparison["comparison_status"], + "not comparable: freshness/quality decision differs", + ) + + def test_failed_quality_is_not_pareto_eligible(self) -> None: + case = { + "passed": False, + "canonical_graph": {"equal": True}, + "oracles": { + "quality": {"passed": False, "passed_count": 1, "applicable_count": 2}, + "marker": { + "elapsed_ms": 1, + "response_bytes": 4, + "response_token_estimate": 1, + }, + }, + "incremental": {"elapsed_ms": 1, "peak_rss_mb": 1}, + "fresh_fast_full_after_change": {"elapsed_ms": 2, "peak_rss_mb": 2}, + } + row = SUMMARY.summarize_group("bad-quality", [report(case)]) + SUMMARY.mark_pareto_frontier([row]) + self.assertEqual(row["decision"], "REJECT: task correctness") + self.assertEqual(row["pareto"], "ineligible") + + def test_atomic_report_write_replaces_content_without_temp_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "summary.md" + SUMMARY.atomic_write_text(output, "first\n") + SUMMARY.atomic_write_text(output, "second\n") + self.assertEqual(output.read_text(), "second\n") + self.assertEqual(list(output.parent.glob(".summary.md.*.tmp")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_token_reduction.c b/tests/test_token_reduction.c new file mode 100644 index 000000000..b941e796c --- /dev/null +++ b/tests/test_token_reduction.c @@ -0,0 +1,2398 @@ +/* + * test_token_reduction.c — Tests for token reduction changes. + * + * Covers: default limits, smart truncation, compact mode, summary mode, + * trace edge cases, query_graph output truncation, token metadata. + * + * TDD: All tests written BEFORE implementation. They should fail (RED) + * until the corresponding feature is implemented (GREEN). + */ +#include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ── Helpers (reuse patterns from test_mcp.c) ────────────────── */ + +/* Forward declaration — definition is in the SEARCH PARAMETERIZATION section */ +static cbm_mcp_server_t *setup_sp_server(void); + +static char *extract_text_content_tr(const char *mcp_result) { + if (!mcp_result) + return NULL; + yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0); + if (!doc) + return strdup(mcp_result); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *content = yyjson_obj_get(root, "content"); + if (!content || !yyjson_is_arr(content)) { + yyjson_doc_free(doc); + return strdup(mcp_result); + } + yyjson_val *item = yyjson_arr_get(content, 0); + if (!item) { + yyjson_doc_free(doc); + return strdup(mcp_result); + } + yyjson_val *text = yyjson_obj_get(item, "text"); + const char *str = yyjson_get_str(text); + char *result = str ? strdup(str) : strdup(mcp_result); + yyjson_doc_free(doc); + return result; +} + +/* Create an MCP server pre-populated with many functions for limit testing. + * Writes a source file with 80 small functions to tmp_dir/project/many.py. + * Returns NULL on failure. Caller must free server and call cleanup. */ +static cbm_mcp_server_t *setup_limit_test_server(char *tmp_dir, size_t tmp_sz) { + snprintf(tmp_dir, tmp_sz, "/tmp/cbm_limit_test_XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) + return NULL; + + char proj_dir[512]; + snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp_dir); + cbm_mkdir(proj_dir); + + /* Write source file with many functions */ + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/many.py", proj_dir); + FILE *fp = fopen(src_path, "w"); + if (!fp) + return NULL; + for (int i = 0; i < 80; i++) { + fprintf(fp, "def func_%03d():\n pass\n\n", i); + } + fclose(fp); + + /* Write a large function for truncation tests */ + char big_path[512]; + snprintf(big_path, sizeof(big_path), "%s/big.py", proj_dir); + fp = fopen(big_path, "w"); + if (!fp) + return NULL; + fprintf(fp, "def large_function(arg1, arg2, arg3):\n"); + fprintf(fp, " \"\"\"Process data with multiple steps.\"\"\"\n"); + for (int i = 2; i < 298; i++) { + fprintf(fp, " step_%03d = process(arg1, %d)\n", i, i); + } + fprintf(fp, " result = combine(step_002, step_297)\n"); + fprintf(fp, " return result\n"); + fclose(fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) + return NULL; + + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + cbm_mcp_server_free(srv); + return NULL; + } + + const char *proj_name = "limit-test"; + cbm_mcp_server_set_project(srv, proj_name); + cbm_store_upsert_project(st, proj_name, proj_dir); + + /* Create 80 function nodes */ + for (int i = 0; i < 80; i++) { + cbm_node_t n = {0}; + n.project = proj_name; + n.label = "Function"; + char name_buf[32], qn_buf[64]; + snprintf(name_buf, sizeof(name_buf), "func_%03d", i); + snprintf(qn_buf, sizeof(qn_buf), "limit-test.many.func_%03d", i); + n.name = name_buf; + n.qualified_name = qn_buf; + n.file_path = "many.py"; + n.start_line = i * 3 + 1; + n.end_line = i * 3 + 2; + n.properties_json = "{\"is_exported\":true}"; + cbm_store_upsert_node(st, &n); + } + + /* Create a large function node for truncation tests */ + cbm_node_t big = {0}; + big.project = proj_name; + big.label = "Function"; + big.name = "large_function"; + big.qualified_name = "limit-test.big.large_function"; + big.file_path = "big.py"; + big.start_line = 1; + big.end_line = 300; + big.properties_json = "{\"signature\":\"def large_function(arg1, arg2, arg3)\"," + "\"return_type\":\"result\",\"is_exported\":true}"; + cbm_store_upsert_node(st, &big); + + /* Create call chain for trace tests: func_000 -> func_001 -> func_002 */ + int64_t id0 = 1, id1 = 2, id2 = 3; /* approximate IDs */ + cbm_edge_t e1 = {.project = proj_name, .source_id = id0, .target_id = id1, .type = "CALLS"}; + cbm_store_insert_edge(st, &e1); + cbm_edge_t e2 = {.project = proj_name, .source_id = id1, .target_id = id2, .type = "CALLS"}; + cbm_store_insert_edge(st, &e2); + /* Create cycle: func_002 -> func_000 */ + cbm_edge_t e3 = {.project = proj_name, .source_id = id2, .target_id = id0, .type = "CALLS"}; + cbm_store_insert_edge(st, &e3); + + return srv; +} + +static void cleanup_limit_test_dir(const char *tmp_dir) { + char path[512]; + snprintf(path, sizeof(path), "%s/project/many.py", tmp_dir); + cbm_unlink(path); + snprintf(path, sizeof(path), "%s/project/big.py", tmp_dir); + cbm_unlink(path); + snprintf(path, sizeof(path), "%s/project", tmp_dir); + cbm_rmdir(path); + cbm_rmdir(tmp_dir); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.1 DEFAULT LIMITS + * ══════════════════════════════════════════════════════════════════ */ + +TEST(search_graph_default_limit_is_50) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* search_graph with no limit parameter — should default to 50. + * format:"json" opts into the legacy JSON shape this test parses. */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Parse response to count results */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + ASSERT_NOT_NULL(results); + ASSERT_TRUE(yyjson_arr_size(results) <= 50); + + /* total should reflect all 80 functions */ + yyjson_val *total = yyjson_obj_get(root, "total"); + ASSERT_TRUE(yyjson_get_int(total) >= 80); + + /* has_more should be true since 80 > 50 */ + yyjson_val *has_more = yyjson_obj_get(root, "has_more"); + ASSERT_TRUE(yyjson_get_bool(has_more)); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_explicit_limit_honored) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"limit\":5,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + ASSERT_EQ((int)yyjson_arr_size(results), 5); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_explicit_high_limit_still_works) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Explicit limit=1000 should override default and return all 80+ */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"limit\":1000,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + /* Should get all 80+ nodes (80 funcs + 1 large_function) */ + ASSERT_TRUE((int)yyjson_arr_size(results) > 50); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_code_default_limit_is_50) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* search_code for "def " should match all 81 functions but return ≤50 */ + char *raw = cbm_mcp_handle_tool(srv, "search_code", + "{\"project\":\"limit-test\",\"pattern\":\"def \"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + if (doc) { + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + if (results && yyjson_is_arr(results)) { + ASSERT_TRUE((int)yyjson_arr_size(results) <= 50); + } + yyjson_doc_free(doc); + } + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_pagination_stable_ordering) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Page 1: offset=0, limit=10 (format:"json" — this test parses JSON) */ + char *raw1 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"limit\":10,\"offset\":0,\"format\":\"json\"}"); + char *resp1 = extract_text_content_tr(raw1); + free(raw1); + + /* Page 2: offset=10, limit=10 */ + char *raw2 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"limit\":10,\"offset\":10,\"format\":\"json\"}"); + char *resp2 = extract_text_content_tr(raw2); + free(raw2); + + ASSERT_NOT_NULL(resp1); + ASSERT_NOT_NULL(resp2); + + /* Pages should not overlap — check first result of page 2 is not in page 1 */ + yyjson_doc *d2 = yyjson_read(resp2, strlen(resp2), 0); + if (d2) { + yyjson_val *r2 = yyjson_doc_get_root(d2); + yyjson_val *res2 = yyjson_obj_get(r2, "results"); + if (res2 && yyjson_arr_size(res2) > 0) { + yyjson_val *first = yyjson_arr_get(res2, 0); + yyjson_val *qn = yyjson_obj_get(first, "qualified_name"); + const char *qn_str = yyjson_get_str(qn); + if (qn_str) { + /* This QN should NOT appear in page 1 */ + ASSERT_NULL(strstr(resp1, qn_str)); + } + } + yyjson_doc_free(d2); + } + + free(resp1); + free(resp2); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.2 SMART TRUNCATION + * ══════════════════════════════════════════════════════════════════ */ + +TEST(snippet_full_mode_default_200_lines) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.big.large_function\"," + "\"project\":\"limit-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Should be truncated since function is 300 lines, default max_lines=200 */ + ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true")); + ASSERT_NOT_NULL(strstr(resp, "\"total_lines\":300")); + /* Signature should still be present for structural context */ + ASSERT_NOT_NULL(strstr(resp, "large_function")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(snippet_full_mode_small_function_no_truncation) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* func_000 is only 2 lines — should NOT be truncated */ + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.many.func_000\"," + "\"project\":\"limit-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NULL(strstr(resp, "\"truncated\":true")); + ASSERT_NOT_NULL(strstr(resp, "\"source\"")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(snippet_signature_mode) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.big.large_function\"," + "\"project\":\"limit-test\",\"mode\":\"signature\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Should contain signature from properties */ + ASSERT_NOT_NULL(strstr(resp, "def large_function(arg1, arg2, arg3)")); + /* Should NOT contain full source body */ + ASSERT_NULL(strstr(resp, "step_050")); + /* Signature is a complete requested representation, not clipped source. + * Retain size context without telling callers to repair a non-problem. */ + ASSERT_NOT_NULL(strstr(resp, "\"total_lines\":300")); + ASSERT_NULL(strstr(resp, "\"truncated\":true")); + ASSERT_NULL(strstr(resp, "\"source_clipped\":true")); + ASSERT_NULL(strstr(resp, "\"clipped_at_lines\"")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(snippet_head_tail_mode) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.big.large_function\"," + "\"project\":\"limit-test\"," + "\"mode\":\"head_tail\",\"max_lines\":100}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Head (first 60 lines) should include the function def */ + ASSERT_NOT_NULL(strstr(resp, "def large_function")); + /* Tail (last 40 lines) should include the return statement */ + ASSERT_NOT_NULL(strstr(resp, "return result")); + /* Omission marker between head and tail */ + ASSERT_NOT_NULL(strstr(resp, "lines omitted")); + ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(snippet_head_tail_no_truncation_when_fits) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* func_000 is 2 lines, head_tail with max_lines=100 should return all */ + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.many.func_000\"," + "\"project\":\"limit-test\"," + "\"mode\":\"head_tail\",\"max_lines\":100}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NULL(strstr(resp, "lines omitted")); + ASSERT_NULL(strstr(resp, "\"truncated\":true")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(snippet_custom_max_lines) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.big.large_function\"," + "\"project\":\"limit-test\",\"max_lines\":50}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true")); + ASSERT_NOT_NULL(strstr(resp, "\"total_lines\":300")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(snippet_max_lines_zero_means_unlimited) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* max_lines=0 should return full source without truncation */ + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"limit-test.big.large_function\"," + "\"project\":\"limit-test\",\"max_lines\":0}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Should NOT be truncated */ + ASSERT_NULL(strstr(resp, "\"truncated\":true")); + /* Should contain content from near the end of the function */ + ASSERT_NOT_NULL(strstr(resp, "return result")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.3 COMPACT MODE + * ══════════════════════════════════════════════════════════════════ */ + +TEST(search_graph_compact_omits_redundant_name) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"limit\":5,\"compact\":true,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* In compact mode, results should have qualified_name but + * name should be omitted when it's a suffix of qualified_name. + * All our test functions have name == last segment of QN, + * so name should be omitted for all results. */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + ASSERT_NOT_NULL(results); + + /* Check first result has qualified_name but no name */ + yyjson_val *first = yyjson_arr_get(results, 0); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name")); + ASSERT_NULL(yyjson_obj_get(first, "name")); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(trace_compact_omits_redundant_name) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"func_000\"," + "\"project\":\"limit-test\",\"compact\":true," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Callees should use compact format */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + if (doc) { + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *callees = yyjson_obj_get(root, "callees"); + if (callees && yyjson_arr_size(callees) > 0) { + yyjson_val *first = yyjson_arr_get(callees, 0); + ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name")); + /* name should be omitted in compact mode */ + ASSERT_NULL(yyjson_obj_get(first, "name")); + } + yyjson_doc_free(doc); + } + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_compact_defaults_to_true) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* No compact/format params -> the default output is the compact TOON + * encoding (upstream 4843a340): scalar lines plus a + * results[N]{qn,label,file,lines,in,out} table whose rows are keyed by + * qualified name only — no redundant per-row "name" field exists at all. */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"include_dependencies\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* TOON contract: leading `key: value` scalar, table header, known row. */ + ASSERT_EQ(strncmp(resp, "total: ", 7), 0); + const char *hdr = strstr(resp, "results[3]{qn,label,file,lines,in,out}:\n"); + ASSERT_NOT_NULL(hdr); + ASSERT_NOT_NULL(strstr(resp, "\n sp-test.main.main,Function,main.py,1-5,")); + /* Header count matches the actual number of indented rows in THIS table. + * Stop at the first non-indented line: the first-response _context header + * (native TOON) appends its own 2-space-indented sub-tables right after + * this one, so an unbounded "\n " scan would double-count their rows. */ + int rows = 0; + const char *row_start = strchr(hdr, '\n'); + ASSERT_NOT_NULL(row_start); + row_start++; + while (strncmp(row_start, " ", 2) == 0) { + rows++; + const char *next = strchr(row_start, '\n'); + if (!next) break; + row_start = next + 1; + } + ASSERT_EQ(rows, 3); + /* Compact default: no verbose JSON "name" field anywhere. */ + ASSERT_NULL(strstr(resp, "\"name\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_compact_false_includes_name) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"include_dependencies\":false," + "\"compact\":false,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_GT((int)yyjson_arr_size(results), 0); + yyjson_val *first = yyjson_arr_get(results, 0); + /* compact=false: name field present even when name matches qn suffix */ + ASSERT_NOT_NULL(yyjson_obj_get(first, "name")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.4 SUMMARY MODE + * ══════════════════════════════════════════════════════════════════ */ + +TEST(search_graph_summary_mode) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\"," + "\"mode\":\"summary\",\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Should have aggregate fields + G1: empty results array (not suppressed) */ + ASSERT_NOT_NULL(strstr(resp, "\"total\"")); + ASSERT_NOT_NULL(strstr(resp, "\"by_label\"")); + /* G1: summary mode now includes "results":[] and "results_suppressed":true */ + ASSERT_NOT_NULL(strstr(resp, "\"results\"")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_summary_counts_every_matching_node) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + /* The former implementation materialized only the first 10,000 matches. + * Keep the decisive label last in stable name order so a sampled summary + * cannot accidentally pass. One transaction keeps this scale canary fast. */ + ASSERT_EQ(cbm_store_begin(st), CBM_STORE_OK); + for (int i = 0; i < 10004; i++) { + char name[32], qn[64]; + snprintf(name, sizeof(name), "summary_%05d", i); + snprintf(qn, sizeof(qn), "limit-test.summary.%05d", i); + cbm_node_t node = { + .project = "limit-test", + .label = "SummaryBaseCanary", + .name = name, + .qualified_name = qn, + .file_path = "summary/base.c", + }; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + } + cbm_node_t tail = { + .project = "limit-test", + .label = "SummaryTailCanary", + .name = "zz_summary_tail", + .qualified_name = "limit-test.summary.zz_tail", + .file_path = "summary/tail.c", + }; + ASSERT_GT(cbm_store_upsert_node(st, &tail), 0); + ASSERT_EQ(cbm_store_commit(st), CBM_STORE_OK); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\"," + "\"mode\":\"summary\",\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *total = yyjson_obj_get(root, "total"); + yyjson_val *by_label = yyjson_obj_get(root, "by_label"); + yyjson_val *context = yyjson_obj_get(root, "_context"); + ASSERT_NOT_NULL(total); + ASSERT_NOT_NULL(by_label); + ASSERT_NOT_NULL(context); + ASSERT_NOT_NULL(yyjson_obj_get(context, "node_labels")); + ASSERT_NOT_NULL(yyjson_obj_get(context, "edge_types")); + ASSERT_TRUE(yyjson_is_obj(by_label)); + ASSERT_EQ(yyjson_get_int(total), 10086); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(by_label, "SummaryBaseCanary")), 10004); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(by_label, "SummaryTailCanary")), 1); + + int64_t summarized = 0; + size_t idx, max; + yyjson_val *key, *value; + yyjson_obj_foreach(by_label, idx, max, key, value) { + (void)key; + summarized += yyjson_get_sint(value); + } + ASSERT_EQ(summarized, yyjson_get_sint(total)); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_summary_ranks_top_files_after_filtering) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + /* Twenty singleton files sort before popular.c. A first-distinct-files + * implementation therefore omits the actual highest-frequency file. */ + ASSERT_EQ(cbm_store_begin(st), CBM_STORE_OK); + for (int i = 0; i < 20; i++) { + char name[32], qn[64], file[32]; + snprintf(name, sizeof(name), "a_singleton_%02d", i); + snprintf(qn, sizeof(qn), "limit-test.file.singleton_%02d", i); + snprintf(file, sizeof(file), "singleton_%02d.c", i); + cbm_node_t node = { + .project = "limit-test", + .label = "SummaryFileCanary", + .name = name, + .qualified_name = qn, + .file_path = file, + }; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + } + for (int i = 0; i < 6; i++) { + char name[32], qn[64]; + snprintf(name, sizeof(name), "z_popular_%02d", i); + snprintf(qn, sizeof(qn), "limit-test.file.popular_%02d", i); + cbm_node_t node = { + .project = "limit-test", + .label = "SummaryFileCanary", + .name = name, + .qualified_name = qn, + .file_path = "popular.c", + }; + ASSERT_GT(cbm_store_upsert_node(st, &node), 0); + } + ASSERT_EQ(cbm_store_commit(st), CBM_STORE_OK); + + char *raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"SummaryFileCanary\"," + "\"mode\":\"summary\",\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *by_file = yyjson_obj_get(root, "by_file_top20"); + ASSERT_NOT_NULL(by_file); + ASSERT_TRUE(yyjson_is_obj(by_file)); + ASSERT_EQ(yyjson_obj_size(by_file), 20); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(by_file, "popular.c")), 6); + const char *popular_pos = strstr(resp, "\"popular.c\":6"); + const char *singleton_pos = strstr(resp, "\"singleton_00.c\":1"); + ASSERT_NOT_NULL(popular_pos); + ASSERT_NOT_NULL(singleton_pos); + ASSERT_TRUE(popular_pos < singleton_pos); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(search_graph_summary_default_format_suppresses_node_rows) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "limit-test"); + + char *raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"limit-test\",\"mode\":\"summary\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NOT_NULL(strstr(resp, "by_label")); + ASSERT_NOT_NULL(strstr(resp, "by_file_top20")); + ASSERT_NOT_NULL(strstr(resp, "results_suppressed")); + ASSERT_NULL(strstr(resp, "limit-test.many.func_000")); + ASSERT_NOT_NULL(strstr(resp, "session_project: limit-test")); + ASSERT_NOT_NULL(strstr(resp, "_context_status")); + ASSERT_NOT_NULL(strstr(resp, "_context_project: limit-test")); + ASSERT_NOT_NULL(strstr(resp, "_context_nodes: 81")); + ASSERT_NOT_NULL(strstr(resp, "_context_edges: 3")); + ASSERT_NOT_NULL(strstr(resp, "_context_count_read_model: canonical_only")); + ASSERT_NOT_NULL(strstr(resp, "_context_coverage_status: unavailable")); + ASSERT_NOT_NULL(strstr(resp, "_context_coverage_action:")); + ASSERT_NOT_NULL(strstr(resp, "check_index_coverage")); + ASSERT_NOT_NULL(strstr(resp, "_context_node_labels")); + ASSERT_NOT_NULL(strstr(resp, "_context_edge_types")); + + free(resp); + + raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"limit-test\",\"mode\":\"summary\"}"); + resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "_context_status")); + ASSERT_NOT_NULL(strstr(resp, "session_project")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.5 TRACE EDGE CASES + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_ambiguous_function_returns_suggestions) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Add a second node with same short name but different QN */ + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_node_t dup = {0}; + dup.project = "limit-test"; + dup.label = "Function"; + dup.name = "func_000"; + dup.qualified_name = "limit-test.other.func_000"; + dup.file_path = "other.py"; + dup.start_line = 1; + dup.end_line = 2; + cbm_store_upsert_node(st, &dup); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"func_000\"," + "\"project\":\"limit-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Upstream 7d6d52b9: two *real* same-named callable definitions are + * genuinely ambiguous — trace_path must return status:ambiguous with a + * suggestions list (both QNs) instead of silently picking/unioning one. */ + ASSERT_NOT_NULL(strstr(resp, "ambiguous")); + ASSERT_NOT_NULL(strstr(resp, "\"suggestions\"")); + ASSERT_NOT_NULL(strstr(resp, "limit-test.many.func_000")); + ASSERT_NOT_NULL(strstr(resp, "limit-test.other.func_000")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(trace_bfs_deduplicates_cycles) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* func_000 -> func_001 -> func_002 -> func_000 (cycle) + * BFS should visit each node at most once in results */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"func_000\"," + "\"project\":\"limit-test\"," + "\"direction\":\"outbound\",\"depth\":5," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + if (doc) { + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *callees = yyjson_obj_get(root, "callees"); + if (callees) { + /* Should have at most 2 unique callees (func_001, func_002) + * NOT 4+ from the cycle being traversed multiple times */ + ASSERT_TRUE((int)yyjson_arr_size(callees) <= 3); + } + yyjson_doc_free(doc); + } + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(trace_max_results_parameter) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"func_000\"," + "\"project\":\"limit-test\"," + "\"max_results\":1,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + if (doc) { + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *callees = yyjson_obj_get(root, "callees"); + if (callees) { + ASSERT_TRUE((int)yyjson_arr_size(callees) <= 1); + } + yyjson_doc_free(doc); + } + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.7 QUERY_GRAPH OUTPUT TRUNCATION + * ══════════════════════════════════════════════════════════════════ */ + +TEST(query_graph_max_output_bytes_truncates) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Query that returns many rows, but cap output at 1024 bytes */ + char *raw = cbm_mcp_handle_tool(srv, "query_graph", + "{\"query\":\"MATCH (f:Function) RETURN f.name, " + "f.qualified_name, f.file_path\"," + "\"project\":\"limit-test\"," + "\"max_output_bytes\":1024}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Response should indicate truncation */ + ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true")); + ASSERT_NOT_NULL(strstr(resp, "Narrow returned fields, add LIMIT when appropriate, or raise " + "max_output_bytes")); + /* The reply carries no rows, so it must not claim rows were returned: + * rows_materialized counts rows the engine produced before the byte cap, + * and rows_returned states the zero rows actually present in this reply. */ + ASSERT_NOT_NULL(strstr(resp, "\"rows_materialized\":")); + ASSERT_NOT_NULL(strstr(resp, "\"rows_returned\":0")); + /* Response body should be near the byte limit */ + ASSERT_TRUE(strlen(resp) <= 2048); /* some slack for metadata */ + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(query_graph_aggregation_not_broken) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Aggregation query should return correct count regardless of limits */ + char *raw = cbm_mcp_handle_tool(srv, "query_graph", + "{\"query\":\"MATCH (f:Function) RETURN count(f)\"," + "\"project\":\"limit-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Should NOT be truncated (aggregation returns 1 small row) */ + ASSERT_NULL(strstr(resp, "\"truncated\":true")); + /* Should contain a count ≥ 80 (our 80 funcs + large_function) */ + ASSERT_NOT_NULL(strstr(resp, "rows")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +TEST(query_graph_max_output_bytes_zero_unlimited) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* max_output_bytes=0 should disable truncation */ + char *raw = cbm_mcp_handle_tool(srv, "query_graph", + "{\"query\":\"MATCH (f:Function) RETURN f.name\"," + "\"project\":\"limit-test\"," + "\"max_output_bytes\":0}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + ASSERT_NULL(strstr(resp, "\"truncated\":true")); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.8 TOKEN METADATA + * ══════════════════════════════════════════════════════════════════ */ + +TEST(response_includes_meta_fields) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"limit-test\",\"label\":\"Function\"," + "\"limit\":5}"); + ASSERT_NOT_NULL(raw); + + /* Token metadata is in the MCP envelope (cbm_mcp_text_result output) */ + ASSERT_NOT_NULL(strstr(raw, "\"_result_bytes\"")); + ASSERT_NOT_NULL(strstr(raw, "\"_est_tokens\"")); + + free(raw); + cbm_mcp_server_free(srv); + cleanup_limit_test_dir(tmp); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 1.9 FIELD OMISSION (empty label / file_path not emitted) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(search_graph_omits_empty_label_and_file_path) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "empty-test"); + cbm_store_upsert_project(st, "empty-test", "/tmp"); + + /* Node with empty label and empty file_path */ + cbm_node_t n = {0}; + n.project = "empty-test"; + n.label = ""; + n.name = "anon_func"; + n.qualified_name = "empty-test.mod.anon_func"; + n.file_path = ""; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"empty-test\",\"compact\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* Empty label and file_path must be omitted, not emitted as "" */ + ASSERT_NULL(yyjson_obj_get(item, "label")); + ASSERT_NULL(yyjson_obj_get(item, "file_path")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_includes_nonempty_label_and_file_path) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "nonempty-test"); + cbm_store_upsert_project(st, "nonempty-test", "/tmp"); + + cbm_node_t n = {0}; + n.project = "nonempty-test"; + n.label = "Function"; + n.name = "do_work"; + n.qualified_name = "nonempty-test.worker.do_work"; + n.file_path = "worker.py"; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"nonempty-test\",\"compact\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* Non-empty label and file_path must be present with correct values */ + ASSERT_NOT_NULL(yyjson_obj_get(item, "label")); + ASSERT_NOT_NULL(yyjson_obj_get(item, "file_path")); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "label")), "Function"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "file_path")), "worker.py"); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* TDD: Zero in_degree/out_degree fields omitted when no edges. + * RED until Change 3 (zero degree omission) is implemented in mcp.c. */ +TEST(search_graph_omits_zero_degrees) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "degree-test"); + cbm_store_upsert_project(st, "degree-test", "/tmp"); + + /* Node with no edges -> in_degree=0, out_degree=0 */ + cbm_node_t n = {0}; + n.project = "degree-test"; + n.label = "Function"; + n.name = "isolated"; + n.qualified_name = "degree-test.mod.isolated"; + n.file_path = "mod.py"; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"degree-test\",\"compact\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* Zero in_degree and out_degree must be omitted, not emitted as 0 */ + ASSERT_NULL(yyjson_obj_get(item, "in_degree")); + ASSERT_NULL(yyjson_obj_get(item, "out_degree")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Non-zero degrees must still be present (regression guard for Change 3). */ +TEST(search_graph_includes_nonzero_degrees) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* process_request has in_degree=2 (CALLS from main, HTTP_CALLS from fetch_data) */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"qn_pattern\":\".*process_request.*\"," + "\"include_dependencies\":false," + "\"compact\":false,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* process_request has non-zero in_degree -> must be present */ + ASSERT_NOT_NULL(yyjson_obj_get(item, "in_degree")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * SEARCH PARAMETERIZATION ACCURACY + * TDD: Tests written BEFORE implementation. + * RED before changes applied. GREEN after. + * ══════════════════════════════════════════════════════════════════ */ + +/* ── Parameterization test fixture ──────────────────────────── */ +/* + * Creates a minimal server with: + * Project "sp-test": + * node id=1: Function name="main" qn="sp-test.main.main" + * no inbound CALLS (in_deg=0 — entry point) + * node id=2: Function name="process_request" qn="sp-test.handlers.process_request" + * inbound CALLS from main (in_deg=1) + * node id=3: Function name="fetch_data" qn="sp-test.http.fetch_data" + * outbound HTTP_CALLS to process_request (in_deg=0) + * Project "sp-test.dep.mypkg": + * node id=4: Function name="dep_helper" qn="sp-test.dep.mypkg.dep_helper" + * + * Edges: + * CALLS: id=1 -> id=2 (main calls process_request) + * HTTP_CALLS: id=3 -> id=2 (fetch_data HTTP calls to process_request) + * + * Node IDs are predictable: fresh in-memory SQLite, autoincrement from 1. + */ +static cbm_mcp_server_t *setup_sp_server(void) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) + return NULL; + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) { + cbm_mcp_server_free(srv); + return NULL; + } + + cbm_mcp_server_set_project(srv, "sp-test"); + cbm_store_upsert_project(st, "sp-test", "/tmp"); + cbm_store_upsert_project(st, "sp-test.dep.mypkg", "/tmp/dep"); + + cbm_node_t n1 = {0}; + n1.project = "sp-test"; + n1.label = "Function"; + n1.name = "main"; + n1.qualified_name = "sp-test.main.main"; + n1.file_path = "main.py"; + n1.start_line = 1; + n1.end_line = 5; + n1.properties_json = "{}"; + cbm_store_upsert_node(st, &n1); + + cbm_node_t n2 = {0}; + n2.project = "sp-test"; + n2.label = "Function"; + n2.name = "process_request"; + n2.qualified_name = "sp-test.handlers.process_request"; + n2.file_path = "handlers.py"; + n2.start_line = 1; + n2.end_line = 10; + n2.properties_json = "{}"; + cbm_store_upsert_node(st, &n2); + + cbm_node_t n3 = {0}; + n3.project = "sp-test"; + n3.label = "Function"; + n3.name = "fetch_data"; + n3.qualified_name = "sp-test.http.fetch_data"; + n3.file_path = "http.py"; + n3.start_line = 1; + n3.end_line = 8; + n3.properties_json = "{}"; + cbm_store_upsert_node(st, &n3); + + cbm_node_t n4 = {0}; + n4.project = "sp-test.dep.mypkg"; + n4.label = "Function"; + n4.name = "dep_helper"; + n4.qualified_name = "sp-test.dep.mypkg.dep_helper"; + n4.file_path = "mypkg/helper.py"; + n4.start_line = 1; + n4.end_line = 5; + n4.properties_json = "{}"; + cbm_store_upsert_node(st, &n4); + + /* CALLS: main(id=1) -> process_request(id=2) */ + cbm_edge_t e1 = {0}; + e1.project = "sp-test"; + e1.source_id = 1; + e1.target_id = 2; + e1.type = "CALLS"; + e1.properties_json = "{}"; + cbm_store_insert_edge(st, &e1); + + /* HTTP_CALLS: fetch_data(id=3) -> process_request(id=2) */ + cbm_edge_t e2 = {0}; + e2.project = "sp-test"; + e2.source_id = 3; + e2.target_id = 2; + e2.type = "HTTP_CALLS"; + e2.properties_json = "{}"; + cbm_store_insert_edge(st, &e2); + + return srv; +} + +static int add_trace_test_caller(cbm_mcp_server_t *srv) { + cbm_store_t *st = cbm_mcp_server_store(srv); + if (!st) return -1; + cbm_node_t n = {0}; + n.project = "sp-test"; + n.label = "Function"; + n.name = "test_helper"; + n.qualified_name = "sp-test.tests.test_helper"; + n.file_path = "tests/test_main.py"; + n.start_line = 1; + n.end_line = 4; + n.properties_json = "{}"; + int64_t test_id = cbm_store_upsert_node(st, &n); + if (test_id <= 0) return -1; + + cbm_edge_t e = {0}; + e.project = "sp-test"; + e.source_id = test_id; + e.target_id = 2; /* process_request in setup_sp_server */ + e.type = "CALLS"; + e.properties_json = "{}"; + return cbm_store_insert_edge(st, &e) > 0 ? 0 : -1; +} + +/* ── Changes 2.1 + 1.1 + 1.3: qn_pattern filters qualified_name ── */ + +TEST(search_graph_qn_pattern_filters_results) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"qn_pattern\":\".*handlers.*\"," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + ASSERT_NOT_NULL(results); + /* Only process_request qn contains "handlers". Expect 1 result. + * RED: qn_pattern ignored, returns all 3 project nodes. GREEN: 1. */ + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_qn_pattern_no_match_returns_empty) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"qn_pattern\":\".*nonexistent_module.*\"," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + /* RED: qn_pattern ignored, returns all nodes. GREEN: 0. */ + ASSERT_EQ((int)yyjson_arr_size(results), 0); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Changes 2.2 + 1.1 + 1.3: relationship filters by edge type ── */ + +TEST(search_graph_relationship_filters_to_matching_edge_type) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"relationship\":\"HTTP_CALLS\"," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + /* fetch_data (source) + process_request (target) both involved in HTTP_CALLS. + * main has no HTTP_CALLS edges -> excluded. + * RED: all 3 returned. GREEN: 2 (both endpoints of HTTP_CALLS). */ + ASSERT_EQ((int)yyjson_arr_size(results), 2); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_relationship_nonexistent_type_returns_empty) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"relationship\":\"WRITES\"," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + /* No WRITES edges exist. RED: all nodes returned. GREEN: 0. */ + ASSERT_EQ((int)yyjson_arr_size(results), 0); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Changes 2.3 + 1.2 + 1.3: exclude_entry_points ─────────── */ + +TEST(search_graph_exclude_entry_points_removes_zero_inbound) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"exclude_entry_points\":true," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + /* main(in_deg=0) + fetch_data(in_deg=0) excluded. process_request(in_deg=1) kept. + * RED: all 3 returned. GREEN: 1. */ + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *first = yyjson_arr_get(results, 0); + /* Check qualified_name (always present; name may be omitted by compact=true default) */ + yyjson_val *qn = yyjson_obj_get(first, "qualified_name"); + ASSERT_STR_EQ(yyjson_get_str(qn), "sp-test.handlers.process_request"); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_exclude_entry_points_false_keeps_all) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"exclude_entry_points\":false," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_EQ((int)yyjson_arr_size(results), 3); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Change 1.3: include_dependencies ──────────────────────── */ + +TEST(search_graph_include_dependencies_true_includes_dep_nodes) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* Default: include_dependencies not specified = true */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* dep_helper from sp-test.dep.mypkg should appear in results */ + ASSERT_NOT_NULL(strstr(resp, "dep_helper")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_include_dependencies_false_excludes_dep_nodes) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"include_dependencies\":false," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + /* dep_helper (project=sp-test.dep.mypkg) must NOT appear. + * RED: include_dependencies ignored -- may return 4. GREEN: exactly 3. */ + ASSERT_EQ((int)yyjson_arr_size(results), 3); + ASSERT_NULL(strstr(resp, "dep_helper")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Change 3.1 reverted: trace compact default remains true ─── */ + +TEST(trace_path_compact_defaults_to_true) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* No compact/format params -> compact stays the default, which now means + * the TOON trace encoding: `function:`/`direction:` scalars plus a + * callees[N]{qn,hop} table keyed by qualified name only (no redundant + * per-hop "name" field). compact:false still opts into verbose JSON + * (see trace_path_compact_false_includes_name below). */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"outbound\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* main -> process_request: exactly one hop-1 row, keyed by qn. */ + ASSERT_EQ(strncmp(resp, "function: main\n", 15), 0); + ASSERT_NOT_NULL(strstr(resp, "direction: outbound\n")); + ASSERT_NOT_NULL(strstr(resp, "callees[1]{qn,hop}:\n")); + ASSERT_NOT_NULL(strstr(resp, "\n sp-test.handlers.process_request,1")); + /* Compact default: no verbose JSON "name" field anywhere. */ + ASSERT_NULL(strstr(resp, "\"name\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(trace_path_compact_false_includes_name) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"outbound\"," + "\"compact\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *callees = yyjson_obj_get(root, "callees"); + ASSERT_NOT_NULL(callees); + ASSERT_GT((int)yyjson_arr_size(callees), 0); + yyjson_val *first_callee = yyjson_arr_get(callees, 0); + /* compact=false explicit: name field present even though name matches qn suffix */ + ASSERT_NOT_NULL(yyjson_obj_get(first_callee, "name")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Change 3.2: trace edge_types user param ────────────────── */ + +TEST(trace_path_edge_types_http_calls_traverses_http_edges) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* fetch_data(id=3) has HTTP_CALLS -> process_request(id=2). + * With edge_types=["HTTP_CALLS"] outbound, process_request should appear. + * With CALLS-only (old hardcoded): no CALLS from fetch_data -> empty callees. */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"fetch_data\"," + "\"project\":\"sp-test\"," + "\"direction\":\"outbound\"," + "\"edge_types\":[\"HTTP_CALLS\"]," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *callees = yyjson_obj_get(yyjson_doc_get_root(doc), "callees"); + ASSERT_NOT_NULL(callees); + /* RED: edge_types ignored, CALLS used, fetch_data has no CALLS -> callees empty. + * GREEN: HTTP_CALLS traversed -> process_request in callees. */ + ASSERT_GT((int)yyjson_arr_size(callees), 0); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(trace_path_default_edge_types_calls_only) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* Without edge_types -> default CALLS -> main -> process_request appears */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"outbound\"," + "\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *callees = yyjson_obj_get(yyjson_doc_get_root(doc), "callees"); + /* main has CALLS -> process_request. Default behavior unchanged. */ + ASSERT_NOT_NULL(callees); + ASSERT_GT((int)yyjson_arr_size(callees), 0); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(trace_path_include_tests_filters_test_nodes) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + ASSERT_EQ(add_trace_test_caller(srv), 0); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"process_request\"," + "\"project\":\"sp-test\"," + "\"direction\":\"inbound\"," + "\"compact\":false," + "\"include_tests\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "test_helper")); + ASSERT_NULL(strstr(resp, "\"is_test\"")); + free(resp); + + raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"process_request\"," + "\"project\":\"sp-test\"," + "\"direction\":\"inbound\"," + "\"compact\":false," + "\"include_tests\":true}"); + resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "test_helper")); + ASSERT_NOT_NULL(strstr(resp, "\"is_test\":true")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(trace_path_risk_labels) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"outbound\"," + "\"risk_labels\":true,\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"risk\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(trace_path_exclude_filters_file_paths) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"outbound\"," + "\"exclude\":[\"handlers.py\"]}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *callees = yyjson_obj_get(yyjson_doc_get_root(doc), "callees"); + ASSERT_NOT_NULL(callees); + ASSERT_EQ((int)yyjson_arr_size(callees), 0); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.0 DEFAULT OUTPUT ENCODING + * Upstream 4843a340: the default tool output is TOON, a compact + * multi-line text format — `key: value` scalars and + * `name[N]{cols}:` tables (src/mcp/compact_out.h). format:"json" + * restores the legacy minified JSON. This test pins the TOON + * default across the read-tool surface. + * ══════════════════════════════════════════════════════════════════ */ + +TEST(all_mcp_responses_default_to_toon) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + + const char *tools[] = {"search_graph", "trace_path", "get_architecture", "query_graph"}; + const char *args[] = { + "{\"project\":\"sp-test\",\"limit\":3}", + "{\"function_name\":\"main\",\"project\":\"sp-test\"}", + "{\"project\":\"sp-test\"}", + "{\"query\":\"MATCH (n) RETURN n.name LIMIT 3\",\"project\":\"sp-test\"}" + }; + /* Each default response opens with a TOON scalar or table header... */ + const char *expected_prefix[] = { + "total: ", /* search_graph */ + "function: main\n", /* trace_path */ + "project: sp-test\n", /* get_architecture */ + "rows[" /* query_graph */ + }; + /* ...and carries the expected data for the sp-test fixture. */ + const char *expected_content[] = { + "results[", /* search_graph table header */ + "callees[", /* trace_path table header */ + "total_nodes: ", /* get_architecture scalar */ + "total: " /* query_graph row-count scalar */ + }; + for (int t = 0; t < 4; t++) { + char *raw = cbm_mcp_handle_tool(srv, tools[t], args[t]); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + /* Default output is TOON text, not a JSON object. */ + ASSERT_TRUE(text[0] != '{'); + ASSERT_EQ(strncmp(text, expected_prefix[t], strlen(expected_prefix[t])), 0); + ASSERT_NOT_NULL(strstr(text, expected_content[t])); + free(text); + } + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(first_graph_tool_response_always_includes_project_context) { + const char *tools[] = { + "search_graph", + "query_graph", + "search_code", + "trace_path", + "get_code", + }; + const char *args[] = { + "{\"project\":\"sp-test\",\"limit\":1,\"format\":\"json\"}", + "{\"project\":\"sp-test\",\"query\":\"MATCH (n) RETURN n.name LIMIT 1\"," + "\"format\":\"json\"}", + "{\"project\":\"sp-test\",\"pattern\":\"main\",\"format\":\"json\"}", + "{\"project\":\"sp-test\",\"function_name\":\"main\",\"format\":\"json\"}", + "{\"project\":\"sp-test\",\"qualified_name\":\"sp-test.main.main\"," + "\"format\":\"json\"}", + }; + + for (size_t i = 0; i < sizeof(tools) / sizeof(tools[0]); i++) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "sp-test"); + + char *raw = cbm_mcp_handle_tool(srv, tools[i], args[i]); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "session_project")), "sp-test"); + yyjson_val *context = yyjson_obj_get(root, "_context"); + ASSERT_NOT_NULL(context); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(context, "status")), "ready"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(context, "project")), "sp-test"); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(context, "nodes")), 3); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(context, "edges")), 2); + ASSERT_NOT_NULL(yyjson_obj_get(context, "node_labels")); + ASSERT_NOT_NULL(yyjson_obj_get(context, "edge_types")); + yyjson_val *coverage = yyjson_obj_get(context, "coverage"); + ASSERT_NOT_NULL(coverage); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(coverage, "status")), "unavailable"); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(coverage, "action")), + "check_index_coverage")); + yyjson_val *architecture = yyjson_obj_get(context, "architecture"); + ASSERT_NOT_NULL(architecture); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "status")), "unavailable"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(architecture, "rank_enabled"))); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(architecture, "key_functions_available"))); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "action")), "index_repository")); + yyjson_doc_free(doc); + free(text); + + raw = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"sp-test\",\"limit\":1,\"format\":\"json\"}"); + text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + root = yyjson_doc_get_root(doc); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(root, "session_project")), "sp-test"); + ASSERT_NULL(yyjson_obj_get(root, "_context")); + yyjson_doc_free(doc); + free(text); + cbm_mcp_server_free(srv); + } + + PASS(); +} + +TEST(first_graph_tool_response_reports_available_architecture) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_session_project(srv, "sp-test"); + ASSERT_EQ(cbm_store_exec(store, "INSERT INTO pagerank(project,node_id,rank,computed_at) VALUES" + "('sp-test',1,0.9,'2026-07-27T00:00:00Z')," + "('sp-test',2,0.8,'2026-07-27T00:00:00Z')," + "('sp-test',3,0.7,'2026-07-27T00:00:00Z')"), + CBM_STORE_OK); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\",\"limit\":1,\"format\":\"json\"}"); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *context = yyjson_obj_get(yyjson_doc_get_root(doc), "_context"); + ASSERT_NOT_NULL(context); + yyjson_val *architecture = yyjson_obj_get(context, "architecture"); + ASSERT_NOT_NULL(architecture); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "status")), "available"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(architecture, "rank_enabled"))); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(architecture, "key_functions_available"))); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(context, "ranked_nodes")), 3); + yyjson_val *key_functions = yyjson_obj_get(context, "key_functions"); + ASSERT_NOT_NULL(key_functions); + ASSERT_TRUE(yyjson_arr_size(key_functions) > 0); + + yyjson_doc_free(doc); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(first_graph_tool_response_explains_stale_architecture) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_session_project(srv, "sp-test"); + const char *stale_views[] = {CBM_STORE_DERIVED_VIEW_PAGERANK}; + ASSERT_EQ(cbm_store_mark_derived_views_stale( + store, "sp-test", CBM_STORE_DERIVED_GENERATION_UNKNOWN, stale_views, + (int)(sizeof(stale_views) / sizeof(stale_views[0]))), + CBM_STORE_OK); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\",\"limit\":1,\"format\":\"json\"}"); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *context = yyjson_obj_get(yyjson_doc_get_root(doc), "_context"); + ASSERT_NOT_NULL(context); + yyjson_val *architecture = yyjson_obj_get(context, "architecture"); + ASSERT_NOT_NULL(architecture); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "status")), "stale"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(architecture, "rank_enabled"))); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "rank_refresh")), + CBM_RANK_REFRESH_DEFAULT); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(architecture, "key_functions_available"))); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "detail")), "key_functions")); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "action")), CBM_CONFIG_RANK_REFRESH)); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "action")), "index_repository")); + ASSERT_NOT_NULL(yyjson_obj_get(context, "warnings")); + ASSERT_NOT_NULL(yyjson_obj_get(context, "freshness")); + ASSERT_NULL(yyjson_obj_get(context, "key_functions")); + yyjson_doc_free(doc); + free(text); + cbm_mcp_server_free(srv); + + srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_session_project(srv, "sp-test"); + ASSERT_EQ(cbm_store_mark_derived_views_stale( + store, "sp-test", CBM_STORE_DERIVED_GENERATION_UNKNOWN, stale_views, + (int)(sizeof(stale_views) / sizeof(stale_views[0]))), + CBM_STORE_OK); + raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"project\":\"sp-test\",\"limit\":1}"); + text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "_context_architecture_status: stale")); + ASSERT_NOT_NULL(strstr(text, "_context_architecture_rank_enabled: true")); + ASSERT_NOT_NULL(strstr(text, "_context_architecture_key_functions_available: false")); + ASSERT_NOT_NULL(strstr(text, "_context_architecture_action:")); + ASSERT_NOT_NULL(strstr(text, CBM_CONFIG_RANK_REFRESH)); + ASSERT_NOT_NULL(strstr(text, "_context_warnings[1]{message}:\n" + " \"pagerank derived view is stale;")); + ASSERT_NULL(strstr(text, "_context_warnings[1]{message}:\n ,")); + ASSERT_NOT_NULL(strstr(text, "_context_freshness_state: stale_with_warning")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(first_graph_tool_response_explains_disabled_architecture) { + char *config_dir = th_mktempdir("cbm_context_rank_disabled"); + ASSERT_NOT_NULL(config_dir); + char config_dir_copy[CBM_PATH_MAX]; + ASSERT_TRUE(snprintf(config_dir_copy, sizeof(config_dir_copy), "%s", config_dir) > 0); + cbm_config_t *config = cbm_config_open(config_dir_copy); + ASSERT_NOT_NULL(config); + ASSERT_EQ(cbm_config_set(config, CBM_CONFIG_RANK_ENABLED, "false"), 0); + + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, config); + cbm_mcp_server_set_session_project(srv, "sp-test"); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\",\"limit\":1,\"format\":\"json\"}"); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *context = yyjson_obj_get(yyjson_doc_get_root(doc), "_context"); + ASSERT_NOT_NULL(context); + yyjson_val *architecture = yyjson_obj_get(context, "architecture"); + ASSERT_NOT_NULL(architecture); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "status")), "disabled"); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(architecture, "rank_enabled"))); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(architecture, "key_functions_available"))); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "detail")), CBM_CONFIG_RANK_ENABLED)); + char expected_action[CBM_SZ_128]; + ASSERT_TRUE(snprintf(expected_action, sizeof(expected_action), "config set %s true", + CBM_CONFIG_RANK_ENABLED) > 0); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "action")), expected_action)); + ASSERT_NULL(yyjson_obj_get(context, "key_functions")); + + yyjson_doc_free(doc); + free(text); + cbm_mcp_server_free(srv); + cbm_config_close(config); + th_cleanup(config_dir_copy); + PASS(); +} + +TEST(first_graph_tool_response_reports_empty_store_actionably) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "empty-project", "/tmp"), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, "empty-project"); + cbm_mcp_server_set_session_project(srv, "empty-project"); + + char *raw = cbm_mcp_handle_tool( + srv, "trace_path", + "{\"project\":\"empty-project\",\"function_name\":\"missing\",\"format\":\"json\"}"); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *context = yyjson_obj_get(yyjson_doc_get_root(doc), "_context"); + ASSERT_NOT_NULL(context); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(context, "status")), "empty"); + ASSERT_EQ(yyjson_get_int(yyjson_obj_get(context, "nodes")), 0); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(context, "action_required")), + "index_repository")); + yyjson_val *architecture = yyjson_obj_get(context, "architecture"); + ASSERT_NOT_NULL(architecture); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "status")), "unavailable"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(architecture, "rank_enabled"))); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(architecture, "key_functions_available"))); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(architecture, "detail")), "ready graph")); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "action")), "_context.action_required")); + + yyjson_doc_free(doc); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(first_tool_response_distinguishes_unresolved_project_from_empty_store) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "not-yet-indexed"); + + char *raw = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + yyjson_doc *doc = yyjson_read(text, strlen(text), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *context = yyjson_obj_get(yyjson_doc_get_root(doc), "_context"); + ASSERT_NOT_NULL(context); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(context, "status")), "not_indexed"); + ASSERT_NULL(yyjson_obj_get(context, "nodes")); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(context, "action_required")), + "project=\"/path/to/repo\"")); + yyjson_val *architecture = yyjson_obj_get(context, "architecture"); + ASSERT_NOT_NULL(architecture); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(architecture, "status")), "unavailable"); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(architecture, "rank_enabled"))); + ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(architecture, "key_functions_available"))); + ASSERT_NOT_NULL(strstr(yyjson_get_str(yyjson_obj_get(architecture, "detail")), "ready graph")); + ASSERT_NOT_NULL( + strstr(yyjson_get_str(yyjson_obj_get(architecture, "action")), "_context.action_required")); + + yyjson_doc_free(doc); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.1 trace_path FIELD OMISSION (TDD) + * Candidates block uses empty-string fallback for file_path (mcp.c:2116). + * RED until candidates block is fixed like search_graph. + * ══════════════════════════════════════════════════════════════════ */ + +/* Empty file_path in a candidate must be omitted, not emitted as "". */ +TEST(trace_path_candidates_omits_empty_file_path) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + + /* Second "main" node with empty file_path forces ambiguity */ + cbm_node_t dup = {0}; + dup.project = "sp-test"; + dup.label = "Function"; + dup.name = "main"; + dup.qualified_name = "sp-test.alt.main"; + dup.file_path = ""; + dup.properties_json = "{}"; + cbm_store_upsert_node(st, &dup); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"candidates\"")); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *candidates = yyjson_obj_get(yyjson_doc_get_root(doc), "candidates"); + ASSERT_NOT_NULL(candidates); + + bool found = false; + for (size_t i = 0; i < yyjson_arr_size(candidates); i++) { + yyjson_val *c = yyjson_arr_get(candidates, i); + yyjson_val *qn = yyjson_obj_get(c, "qualified_name"); + if (qn && strcmp(yyjson_get_str(qn), "sp-test.alt.main") == 0) { + /* Candidate with empty file_path must NOT have the key */ + ASSERT_NULL(yyjson_obj_get(c, "file_path")); + found = true; + } + } + ASSERT_TRUE(found); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Non-empty file_path in candidates must still be present (regression guard). */ +TEST(trace_path_candidates_includes_nonempty_file_path) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + + cbm_node_t dup = {0}; + dup.project = "sp-test"; + dup.label = "Function"; + dup.name = "main"; + dup.qualified_name = "sp-test.alt.main"; + dup.file_path = "alt.py"; + dup.properties_json = "{}"; + cbm_store_upsert_node(st, &dup); + + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *candidates = yyjson_obj_get(yyjson_doc_get_root(doc), "candidates"); + ASSERT_NOT_NULL(candidates); + + /* All candidates here have non-empty file_path -> key must be present */ + for (size_t i = 0; i < yyjson_arr_size(candidates); i++) { + yyjson_val *c = yyjson_arr_get(candidates, i); + ASSERT_NOT_NULL(yyjson_obj_get(c, "file_path")); + } + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.2 get_architecture COMPACT COVERAGE + * key_functions already uses null-guards (if (n), if (lbl), if (fp)). + * Tests verify the contract and that output remains minified. + * ══════════════════════════════════════════════════════════════════ */ + +TEST(get_architecture_output_is_toon_and_no_empty_fields) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "arch-test"); + cbm_store_upsert_project(st, "arch-test", "/tmp"); + + cbm_node_t n = {0}; + n.project = "arch-test"; + n.label = "Function"; + n.name = "entry_point"; + n.qualified_name = "arch-test.main.entry_point"; + n.file_path = "main.py"; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "get_architecture", + "{\"project\":\"arch-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Default output is the TOON summary: scalar header plus tables with the + * single indexed Function accounted for. */ + ASSERT_EQ(strncmp(resp, "project: arch-test\n", 19), 0); + ASSERT_NOT_NULL(strstr(resp, "total_nodes: 1")); + ASSERT_NOT_NULL(strstr(resp, "node_labels[1]{label,count}:\n")); + ASSERT_NOT_NULL(strstr(resp, "\n Function,1")); + + /* No empty fields: TOON renders an empty cell/scalar as "" — none may + * appear (the fixture has no empty name/label/file_path/qn values). */ + ASSERT_NULL(strstr(resp, "\"\"")); + + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.3 trace_path callers_total field + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_path_response_includes_callers_total) { + /* TDD RED: callers_total never emitted (Bug C) — becomes GREEN after fix */ + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* direction=both triggers do_inbound=true; main has no callers but + * callers_total must still appear in the response */ + char *raw = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"both\",\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* callers_total must be present even when callers array is empty */ + ASSERT_NOT_NULL(strstr(resp, "\"callers_total\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.4 get_code_snippet empty field omission + * ══════════════════════════════════════════════════════════════════ */ + +TEST(get_code_snippet_omits_empty_name_label) { + /* TDD RED: name/label emitted as "" when NULL/empty (Bug B) */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "snip-test"); + cbm_store_upsert_project(st, "snip-test", "/tmp"); + + /* Node with empty name and empty label — exercises the "" guard */ + cbm_node_t n = {0}; + n.project = "snip-test"; + n.name = ""; /* empty — should NOT appear as "name":"" */ + n.label = ""; /* empty — should NOT appear as "label":"" */ + n.qualified_name = "snip-test.mod.empty_node"; + n.file_path = ""; /* empty — should NOT appear as "file_path":"" */ + n.start_line = 1; + n.end_line = 2; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"snip-test.mod.empty_node\"," + "\"project\":\"snip-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"name\":\"\"")); + ASSERT_NULL(strstr(resp, "\"label\":\"\"")); + ASSERT_NULL(strstr(resp, "\"file_path\":\"\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.5 get_architecture compact applied to key_functions + * ══════════════════════════════════════════════════════════════════ */ + +TEST(get_architecture_compact_omits_redundant_name_in_key_functions) { + /* TDD RED: key_functions always emits name (Bug A) — becomes GREEN after fix. + * All sp-test nodes have name == last segment of qualified_name, so + * compact should omit every name field in key_functions. */ + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "get_architecture", + "{\"project\":\"sp-test\",\"format\":\"json\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Parse key_functions and assert no entry has a "name" key that equals + * the last segment of its "qualified_name" */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *kfs = yyjson_obj_get(root, "key_functions"); + if (kfs && yyjson_is_arr(kfs)) { + size_t idx, max; + yyjson_val *kf; + yyjson_arr_foreach(kfs, idx, max, kf) { + yyjson_val *name_val = yyjson_obj_get(kf, "name"); + yyjson_val *qn_val = yyjson_obj_get(kf, "qualified_name"); + if (name_val && qn_val) { + const char *nm = yyjson_get_str(name_val); + const char *qn = yyjson_get_str(qn_val); + /* If name is present, it must NOT equal the last segment of qn */ + if (nm && qn) { + size_t qn_len = strlen(qn); + size_t nm_len = strlen(nm); + bool is_suffix = (nm_len < qn_len) && + (qn[qn_len - nm_len - 1] == '.' || + qn[qn_len - nm_len - 1] == ':' || + qn[qn_len - nm_len - 1] == '/') && + strcmp(qn + qn_len - nm_len, nm) == 0; + ASSERT_FALSE(is_suffix); /* compact must have omitted this */ + } + } + } + } + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * SUITE + * ══════════════════════════════════════════════════════════════════ */ + +SUITE(token_reduction) { + /* 1.1 Default Limits */ + RUN_TEST(search_graph_default_limit_is_50); + RUN_TEST(search_graph_explicit_limit_honored); + RUN_TEST(search_graph_explicit_high_limit_still_works); + RUN_TEST(search_code_default_limit_is_50); + RUN_TEST(search_graph_pagination_stable_ordering); + + /* 1.2 Smart Truncation */ + RUN_TEST(snippet_full_mode_default_200_lines); + RUN_TEST(snippet_full_mode_small_function_no_truncation); + RUN_TEST(snippet_signature_mode); + RUN_TEST(snippet_head_tail_mode); + RUN_TEST(snippet_head_tail_no_truncation_when_fits); + RUN_TEST(snippet_custom_max_lines); + RUN_TEST(snippet_max_lines_zero_means_unlimited); + + /* 1.3 Compact Mode */ + RUN_TEST(search_graph_compact_omits_redundant_name); + RUN_TEST(search_graph_compact_defaults_to_true); + RUN_TEST(search_graph_compact_false_includes_name); + RUN_TEST(trace_compact_omits_redundant_name); + + /* 1.4 Summary Mode */ + RUN_TEST(search_graph_summary_mode); + RUN_TEST(search_graph_summary_counts_every_matching_node); + RUN_TEST(search_graph_summary_ranks_top_files_after_filtering); + RUN_TEST(search_graph_summary_default_format_suppresses_node_rows); + + /* 1.5 Trace Edge Cases */ + RUN_TEST(trace_ambiguous_function_returns_suggestions); + RUN_TEST(trace_bfs_deduplicates_cycles); + RUN_TEST(trace_max_results_parameter); + + /* 1.7 query_graph Output Truncation */ + RUN_TEST(query_graph_max_output_bytes_truncates); + RUN_TEST(query_graph_aggregation_not_broken); + RUN_TEST(query_graph_max_output_bytes_zero_unlimited); + + /* 1.8 Token Metadata */ + RUN_TEST(response_includes_meta_fields); + + /* 1.9 Field Omission */ + RUN_TEST(search_graph_omits_empty_label_and_file_path); + RUN_TEST(search_graph_includes_nonempty_label_and_file_path); + RUN_TEST(search_graph_omits_zero_degrees); + RUN_TEST(search_graph_includes_nonzero_degrees); + + /* 2.0 JSON Output Minification */ + RUN_TEST(all_mcp_responses_default_to_toon); + RUN_TEST(first_graph_tool_response_always_includes_project_context); + RUN_TEST(first_graph_tool_response_reports_available_architecture); + RUN_TEST(first_graph_tool_response_explains_stale_architecture); + RUN_TEST(first_graph_tool_response_explains_disabled_architecture); + RUN_TEST(first_graph_tool_response_reports_empty_store_actionably); + RUN_TEST(first_tool_response_distinguishes_unresolved_project_from_empty_store); + + /* 2.1 trace_path Field Omission */ + RUN_TEST(trace_path_candidates_omits_empty_file_path); + RUN_TEST(trace_path_candidates_includes_nonempty_file_path); + + /* 2.2 get_architecture Compact Coverage */ + RUN_TEST(get_architecture_output_is_toon_and_no_empty_fields); + + /* Search Parameterization Accuracy */ + RUN_TEST(search_graph_qn_pattern_filters_results); + RUN_TEST(search_graph_qn_pattern_no_match_returns_empty); + RUN_TEST(search_graph_relationship_filters_to_matching_edge_type); + RUN_TEST(search_graph_relationship_nonexistent_type_returns_empty); + RUN_TEST(search_graph_exclude_entry_points_removes_zero_inbound); + RUN_TEST(search_graph_exclude_entry_points_false_keeps_all); + RUN_TEST(search_graph_include_dependencies_true_includes_dep_nodes); + RUN_TEST(search_graph_include_dependencies_false_excludes_dep_nodes); + RUN_TEST(trace_path_compact_defaults_to_true); + RUN_TEST(trace_path_compact_false_includes_name); + RUN_TEST(trace_path_edge_types_http_calls_traverses_http_edges); + RUN_TEST(trace_path_default_edge_types_calls_only); + RUN_TEST(trace_path_include_tests_filters_test_nodes); + RUN_TEST(trace_path_risk_labels); + RUN_TEST(trace_path_exclude_filters_file_paths); + + /* 2.3 callers_total field completeness */ + RUN_TEST(trace_path_response_includes_callers_total); + + /* 2.4 get_code_snippet empty field omission */ + RUN_TEST(get_code_snippet_omits_empty_name_label); + + /* 2.5 get_architecture compact key_functions */ + RUN_TEST(get_architecture_compact_omits_redundant_name_in_key_functions); +} diff --git a/tests/test_tool_consolidation.c b/tests/test_tool_consolidation.c new file mode 100644 index 000000000..51ac19d97 --- /dev/null +++ b/tests/test_tool_consolidation.c @@ -0,0 +1,3706 @@ +/* + * test_tool_consolidation.c — Tests for the streamlined/default tool surface. + * + * §4b: the search_code_graph mega-tool was deleted; the default surface is now + * 5 focused tools: search_graph, query_graph, search_code, trace_path (from + * TOOLS[]) plus get_code (from STREAMLINED_TOOLS[]). Covers tool + * visibility, split-tool dispatch, get_code alias dispatch, project param path + * support, and tool config visibility. + */ +#include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" +#include "../src/foundation/constants.h" +#include "test_helpers.h" +#include "test_framework.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const yyjson_val *tool_array_from_doc(yyjson_doc *doc) { + yyjson_val *root = yyjson_doc_get_root(doc); + if (!root) return NULL; + yyjson_val *tools = yyjson_obj_get(root, "tools"); + if (tools && yyjson_is_arr(tools)) return tools; + yyjson_val *result = yyjson_obj_get(root, "result"); + if (!result) return NULL; + tools = yyjson_obj_get(result, "tools"); + return tools && yyjson_is_arr(tools) ? tools : NULL; +} + +static bool tool_list_has_exact_name(const char *json, const char *name) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) return false; + const yyjson_val *tools = tool_array_from_doc(doc); + bool found = false; + if (tools) { + yyjson_arr_iter it; + yyjson_arr_iter_init((yyjson_val *)tools, &it); + yyjson_val *tool; + while ((tool = yyjson_arr_iter_next(&it)) != NULL) { + yyjson_val *tool_name = yyjson_obj_get(tool, "name"); + if (tool_name && yyjson_is_str(tool_name) && + strcmp(yyjson_get_str(tool_name), name) == 0) { + found = true; + break; + } + } + } + yyjson_doc_free(doc); + return found; +} + +static size_t tool_list_exact_count(const char *json) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) return 0; + const yyjson_val *tools = tool_array_from_doc(doc); + size_t count = tools ? yyjson_arr_size(tools) : 0; + yyjson_doc_free(doc); + return count; +} + +static bool tool_schema_has_property(const char *json, const char *tool, const char *prop) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) return false; + const yyjson_val *tools = tool_array_from_doc(doc); + bool found = false; + if (tools) { + yyjson_arr_iter it; + yyjson_arr_iter_init((yyjson_val *)tools, &it); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it)) != NULL) { + yyjson_val *name = yyjson_obj_get(item, "name"); + if (!name || !yyjson_is_str(name) || strcmp(yyjson_get_str(name), tool) != 0) { + continue; + } + yyjson_val *schema = yyjson_obj_get(item, "inputSchema"); + yyjson_val *props = schema ? yyjson_obj_get(schema, "properties") : NULL; + found = props && yyjson_obj_get(props, prop) != NULL; + break; + } + } + yyjson_doc_free(doc); + return found; +} + +static bool tool_schema_required_has(const char *json, const char *tool, const char *prop) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) return false; + const yyjson_val *tools = tool_array_from_doc(doc); + bool found = false; + if (tools) { + yyjson_arr_iter it; + yyjson_arr_iter_init((yyjson_val *)tools, &it); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it)) != NULL) { + yyjson_val *name = yyjson_obj_get(item, "name"); + if (!name || !yyjson_is_str(name) || strcmp(yyjson_get_str(name), tool) != 0) { + continue; + } + yyjson_val *schema = yyjson_obj_get(item, "inputSchema"); + yyjson_val *required = schema ? yyjson_obj_get(schema, "required") : NULL; + if (required && yyjson_is_arr(required)) { + yyjson_arr_iter rit; + yyjson_arr_iter_init(required, &rit); + yyjson_val *r; + while ((r = yyjson_arr_iter_next(&rit)) != NULL) { + if (yyjson_is_str(r) && strcmp(yyjson_get_str(r), prop) == 0) { + found = true; + break; + } + } + } + break; + } + } + yyjson_doc_free(doc); + return found; +} + +static bool tool_input_schemas_equal(const char *left_json, const char *right_json, + const char *tool_name) { + yyjson_doc *left_doc = yyjson_read(left_json, strlen(left_json), 0); + yyjson_doc *right_doc = yyjson_read(right_json, strlen(right_json), 0); + if (!left_doc || !right_doc) { + yyjson_doc_free(left_doc); + yyjson_doc_free(right_doc); + return false; + } + + const yyjson_val *left_tools = tool_array_from_doc(left_doc); + const yyjson_val *right_tools = tool_array_from_doc(right_doc); + yyjson_val *left_schema = NULL; + yyjson_val *right_schema = NULL; + yyjson_arr_iter it; + yyjson_val *item; + + if (left_tools) { + yyjson_arr_iter_init((yyjson_val *)left_tools, &it); + while ((item = yyjson_arr_iter_next(&it)) != NULL) { + yyjson_val *name = yyjson_obj_get(item, "name"); + if (name && yyjson_is_str(name) && strcmp(yyjson_get_str(name), tool_name) == 0) { + left_schema = yyjson_obj_get(item, "inputSchema"); + break; + } + } + } + if (right_tools) { + yyjson_arr_iter_init((yyjson_val *)right_tools, &it); + while ((item = yyjson_arr_iter_next(&it)) != NULL) { + yyjson_val *name = yyjson_obj_get(item, "name"); + if (name && yyjson_is_str(name) && strcmp(yyjson_get_str(name), tool_name) == 0) { + right_schema = yyjson_obj_get(item, "inputSchema"); + break; + } + } + } + + bool equal = left_schema && right_schema && yyjson_equals(left_schema, right_schema); + yyjson_doc_free(left_doc); + yyjson_doc_free(right_doc); + return equal; +} + +static char *save_tool_mode(void) { + const char *mode = getenv("CBM_TOOL_MODE"); + if (!mode) return NULL; + size_t len = strlen(mode); + char *copy = (char *)malloc(len + 1); + if (!copy) return NULL; + memcpy(copy, mode, len + 1); + return copy; +} + +static void restore_tool_mode(char *saved) { + if (saved) { + cbm_setenv("CBM_TOOL_MODE", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_TOOL_MODE"); + } +} + +static char *extract_tool_text(const char *mcp_result) { + yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0); + if (!doc) { + return strdup(mcp_result); + } + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *content = yyjson_obj_get(root, "content"); + if (!content || !yyjson_is_arr(content)) { + yyjson_doc_free(doc); + return strdup(mcp_result); + } + yyjson_val *item = yyjson_arr_get(content, 0); + yyjson_val *text = item ? yyjson_obj_get(item, "text") : NULL; + const char *str = text && yyjson_is_str(text) ? yyjson_get_str(text) : mcp_result; + char *copy = strdup(str); + yyjson_doc_free(doc); + return copy; +} + +static bool json_array_has_string(const char *json, const char *array_key, const char *value) { + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) { + return false; + } + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *arr = yyjson_obj_get(root, array_key); + bool found = false; + if (arr && yyjson_is_arr(arr)) { + yyjson_arr_iter it; + yyjson_arr_iter_init(arr, &it); + yyjson_val *item; + while ((item = yyjson_arr_iter_next(&it)) != NULL) { + if (yyjson_is_str(item) && strcmp(yyjson_get_str(item), value) == 0) { + found = true; + break; + } + } + } + yyjson_doc_free(doc); + return found; +} + +/* ── 1. Tool visibility tests ─────────────────────────────── */ + +TEST(streamlined_mode_shows_default_user_tools) { + /* NULL srv → streamlined mode (no config available). + * §4b: default surface is five user-facing tools plus _hidden_tools. + * Canonical tools are emitted from TOOLS[] to avoid schema drift; get_code + * is the concise streamlined alias. The search_code_graph mega-tool is + * gone. */ + char *json = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(json); + /* Default-surface tools must be present by name. */ + ASSERT_NOT_NULL(strstr(json, "search_graph")); + ASSERT_NOT_NULL(strstr(json, "query_graph")); + ASSERT_NOT_NULL(strstr(json, "search_code")); + ASSERT_NOT_NULL(strstr(json, "trace_path")); + ASSERT_NOT_NULL(strstr(json, "get_code")); + /* The deleted mega-tool must NOT appear */ + ASSERT_NULL(strstr(json, "search_code_graph")); + /* Hidden classic-only tools should NOT be top-level entries */ + ASSERT_NULL(strstr(json, "\"index_repository\"")); + ASSERT_NULL(strstr(json, "\"get_code_snippet\"")); + ASSERT_NULL(strstr(json, "\"manage_adr\"")); + free(json); + PASS(); +} + +TEST(query_graph_description_repeats_current_executable_schema) { + /* A stateless catalog has no server cache owner. Its generated description + * must survive the temporary builder's release because yyjson serializes a + * copy, and it must advertise the same invariant contract as live modes. */ + char *stateless_tools = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(stateless_tools); + ASSERT_NOT_NULL( + strstr(stateless_tools, "Stable Cypher capability schema cbm.read-only-cypher/v1")); + ASSERT_NOT_NULL(strstr(stateless_tools, "coalesce/substring/replace/left/right")); + free(stateless_tools); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "schema_docstring"; + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/schema-docstring"), CBM_STORE_OK); + + cbm_node_t source = {.project = project, + .label = "Function", + .name = "source", + .qualified_name = "schema_docstring.source", + .file_path = "schema.c", + .properties_json = "{\"complexity\":2}"}; + cbm_node_t target = {.project = project, + .label = "Function", + .name = "target", + .qualified_name = "schema_docstring.target", + .file_path = "schema.c"}; + int64_t source_id = cbm_store_upsert_node(store, &source); + int64_t target_id = cbm_store_upsert_node(store, &target); + ASSERT_GT(source_id, 0); + ASSERT_GT(target_id, 0); + cbm_edge_t edge = { + .project = project, .source_id = source_id, .target_id = target_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(store, &edge), 0); + + char *saved_mode = save_tool_mode(); + for (int mode = 0; mode < 2; mode++) { + if (mode == 1) { + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + } + for (int relist = 0; relist < 2; relist++) { + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + ASSERT_NOT_NULL(strstr(tools, "Supported read-only Cypher subset")); + ASSERT_NOT_NULL( + strstr(tools, "Stable Cypher capability schema cbm.read-only-cypher/v1")); + ASSERT_NOT_NULL( + strstr(tools, "count/sum/avg/min/max/collect with DISTINCT aggregate arguments")); + ASSERT_NOT_NULL(strstr(tools, "coalesce/substring/replace/left/right")); + ASSERT_NOT_NULL(strstr(tools, "relationship-unique trails")); + ASSERT_NOT_NULL(strstr(tools, "Node properties: name, qualified_name, file_path")); + ASSERT_NOT_NULL(strstr(tools, "complexity")); + ASSERT_NOT_NULL(strstr(tools, "MATCH (source:Function)-[:CALLS]->(target:Function)")); + free(tools); + } + } + restore_tool_mode(saved_mode); + + char *response = cbm_mcp_handle_tool( + srv, "query_graph", "{\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 1\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NULL(strstr(response, "Supported read-only Cypher subset")); + ASSERT_NULL(strstr(response, "Node properties: name, qualified_name, file_path")); + free(response); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* The schema embedded in query_graph must describe the same active overlay + * view that query_graph executes. A ready overlay replaces canonical rows for + * its file; advertising both labels would direct clients to stale vocabulary. */ +TEST(query_graph_description_uses_ready_overlay_view) { + enum { BASE_GENERATION = 1 }; + const char *project = "schema_overlay_view"; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/schema-overlay-view"), CBM_STORE_OK); + + cbm_node_t canonical = {.project = project, + .label = "CanonicalOnlyLabel", + .name = "old", + .qualified_name = "schema_overlay_view.old", + .file_path = "changed.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &canonical), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ( + cbm_store_reserve_overlay_generation(store, project, BASE_GENERATION, &overlay_generation), + CBM_STORE_OK); + cbm_node_t replacement = {.project = project, + .label = "OverlayOnlyLabel", + .name = "new", + .qualified_name = "schema_overlay_view.new", + .file_path = "changed.c"}; + cbm_store_file_delta_t delta = {.project = project, + .rel_path = "changed.c", + .generation = BASE_GENERATION, + .nodes = &replacement, + .node_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(store, &delta, overlay_generation), + CBM_STORE_OK); + + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + ASSERT_NOT_NULL(strstr(tools, "OverlayOnlyLabel")); + ASSERT_NULL(strstr(tools, "CanonicalOnlyLabel")); + free(tools); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* Regression (dogfood 2026-07-18): the very first tools/list of a fresh + * session for an already-indexed project must show the real schema, not + * an empty one. build_query_graph_tool_description previously trusted + * srv->store as-is; on a fresh cbm_mcp_server_new(NULL) srv->store is the + * empty default in-memory store until some tool call lazily resolves the + * real on-disk project store via resolve_store — but session_project is + * set independently (e.g. from CWD at server startup) with no such + * resolution, so the schema section silently rendered empty. Reproduces + * by seeding a REAL on-disk .db (not cbm_mcp_server_store) and setting + * only the session project name before the first tools/list call. */ +TEST(query_graph_description_populated_on_cold_start_tools_list) { + const char *project = "cold_start_schema_docstring"; + + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/cold-start-schema-docstring"), + CBM_STORE_OK); + cbm_node_t source = {.project = project, + .label = "Function", + .name = "source", + .qualified_name = "cold_start_schema_docstring.source", + .file_path = "schema.c"}; + cbm_node_t target = {.project = project, + .label = "Function", + .name = "target", + .qualified_name = "cold_start_schema_docstring.target", + .file_path = "schema.c"}; + int64_t source_id = cbm_store_upsert_node(store, &source); + int64_t target_id = cbm_store_upsert_node(store, &target); + ASSERT_GT(source_id, 0); + ASSERT_GT(target_id, 0); + cbm_edge_t edge = { + .project = project, .source_id = source_id, .target_id = target_id, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(store, &edge), 0); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Only the session project NAME is set (mirrors startup deriving it + * from CWD) — srv->store deliberately never touches the real DB before + * this first tools/list call. */ + cbm_mcp_server_set_session_project(srv, project); + + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + ASSERT_NULL( + strstr(tools, "Labels name{extra property keys}[count]: . Edge types[count]: .")); + ASSERT_NOT_NULL(strstr(tools, "MATCH (source:Function)-[:CALLS]->(target:Function)")); + free(tools); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* RED against the pre-Change-1 zero-row hint (mcp.c handle_query_graph), + * which unconditionally recommended get_graph_schema() — a tool hidden from + * the streamlined default surface (is_streamlined_default_tool) — and never + * named which label/type failed to match. Streamlined mode is this server's + * default (server_default_mode_shows_streamlined_tools above), so this is + * the mode a default client actually sees. */ +TEST(query_graph_zero_row_hint_names_no_hidden_tool) { + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "streamlined", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "hint_no_hidden_tool"; + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/hint-no-hidden-tool"), CBM_STORE_OK); + + cbm_node_t known = {.project = project, + .label = "Function", + .name = "known", + .qualified_name = "hint_no_hidden_tool.known", + .file_path = "hint.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &known), 0); + + char *response = cbm_mcp_handle_tool( + srv, "query_graph", + "{\"query\":\"MATCH (n:NoSuchLabel) RETURN n.name LIMIT 5\"}"); + ASSERT_NOT_NULL(response); + /* Hidden in streamlined mode: recommending it points at an uncallable tool. */ + ASSERT_NULL(strstr(response, "get_graph_schema")); + /* Names the actual unobserved vocabulary instead of a generic sentence. */ + ASSERT_NOT_NULL(strstr(response, "NoSuchLabel")); + free(response); + + cbm_mcp_server_free(srv); + restore_tool_mode(saved_mode); + PASS(); +} + +TEST(query_graph_zero_row_hint_walks_post_with_stage) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + const char *project = "hint_post_with_stage"; + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_session_project(srv, project); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/hint-post-with-stage"), CBM_STORE_OK); + cbm_node_t known = {.project = project, + .label = "Function", + .name = "known", + .qualified_name = "hint_post_with_stage.known", + .file_path = "hint.c"}; + ASSERT_GT(cbm_store_upsert_node(store, &known), 0); + + char *response = + cbm_mcp_handle_tool(srv, "query_graph", + "{\"query\":\"MATCH (n:Function) WITH n " + "MATCH (n)-[:NO_SUCH_EDGE]->(m:NoSuchLabel) RETURN m.name\"}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "NO_SUCH_EDGE")); + ASSERT_NOT_NULL(strstr(response, "NoSuchLabel")); + free(response); + cbm_mcp_server_free(srv); + PASS(); +} + +/* T3 (fragility audit 2026-07-18): hints on graph="missed" must probe the + * shadow project's rows (cbm_store_coverage_shadow_project: ":: + * missed"), never the canonical project — a regression here would + * false-accuse a label that exists only in the missed view, or fail to + * accuse one that is truly absent from it. */ +TEST(query_graph_missed_graph_hint_probes_shadow_project) { + const char *project = "missed_hint_probe"; + + /* resolve_project_store always reopens the project by name from its + * on-disk .db (project_db_path) when an explicit "project" arg is + * given — it never falls back to reusing an in-memory-only store — so + * the fixture must be a real file at that path (same lesson as + * mcp_delete_project_sends_list_changed / the overlay-compaction fix). */ + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/missed-hint-probe"), CBM_STORE_OK); + + /* Canonical project: Route exists, File does not. */ + cbm_node_t canonical_route = {.project = project, + .label = "Route", + .name = "/api", + .qualified_name = "missed_hint_probe./api", + .file_path = "routes.py"}; + ASSERT_GT(cbm_store_upsert_node(store, &canonical_route), 0); + + /* Shadow project: File exists, Route does not. nodes.project has an FK + * to projects(name) (foreign_keys=ON) — the shadow project needs its + * own row first, exactly as the real writer does it (store.c + * cov_rebuild_shadow_graph, cbm_store_upsert_project(s, covproj, "")). */ + char shadow_project[CBM_SZ_256]; + cbm_store_coverage_shadow_project(shadow_project, sizeof(shadow_project), project); + ASSERT_EQ(cbm_store_upsert_project(store, shadow_project, ""), CBM_STORE_OK); + cbm_node_t shadow_file = {.project = shadow_project, + .label = "File", + .name = "unindexed.py", + .qualified_name = "unindexed.py", + .file_path = "unindexed.py"}; + ASSERT_GT(cbm_store_upsert_node(store, &shadow_file), 0); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args_file[CBM_SZ_512]; + snprintf(args_file, sizeof(args_file), + "{\"query\":\"MATCH (n:File) WHERE n.name = 'absent.py' RETURN n.name LIMIT 5\"," + "\"project\":\"%s\",\"graph\":\"missed\"}", + project); + char *resp_file = cbm_mcp_handle_tool(srv, "query_graph", args_file); + ASSERT_NOT_NULL(resp_file); + /* File IS observed in the shadow view: these zero rows come from the + * WHERE predicate matching nothing, not an unknown label — must not + * accuse it. */ + ASSERT_NULL(strstr(resp_file, "Unknown label or edge type: File")); + free(resp_file); + + char args_route[CBM_SZ_512]; + snprintf(args_route, sizeof(args_route), + "{\"query\":\"MATCH (n:Route) RETURN n.name LIMIT 5\",\"project\":\"%s\"," + "\"graph\":\"missed\"}", + project); + char *resp_route = cbm_mcp_handle_tool(srv, "query_graph", args_route); + ASSERT_NOT_NULL(resp_route); + /* Route exists canonically but not in the shadow view: correct to + * accuse it for the view this query actually ran on. */ + ASSERT_NOT_NULL(strstr(resp_route, "Route")); + free(resp_route); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* T4 (fragility audit 2026-07-18): an indexed-but-empty project (row + * exists, zero nodes/edges) must not crash and must not print a hollow + * "Known labels: ." artifact — silence about vocabulary is acceptable when + * there is none. */ +TEST(query_graph_hint_on_empty_project_no_crash_no_false_vocab) { + const char *project = "empty_hint_probe"; + + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/empty-hint-probe"), CBM_STORE_OK); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[CBM_SZ_256]; + snprintf(args, sizeof(args), + "{\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 5\",\"project\":\"%s\"}", project); + char *resp = cbm_mcp_handle_tool(srv, "query_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Function")); /* named in the hint */ + ASSERT_NULL(strstr(resp, "Known labels: .")); /* no empty-list artifact */ + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* T5 (fragility audit 2026-07-18): implements the corrected Change-1 TDD + * gate — TOON and JSON must name the same unobserved labels/types + * (semantic parity), not byte-identical hint strings (the serializers + * escape/quote differently). */ +TEST(query_graph_zero_row_hint_parity_across_formats) { + const char *project = "hint_format_parity"; + + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/hint-format-parity"), CBM_STORE_OK); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + const char *formats[] = {"toon", "json"}; + for (int i = 0; i < 2; i++) { + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), + "{\"query\":\"MATCH (a:NoSuchLabel)-[r:NO_SUCH_TYPE]->(b) RETURN a\"," + "\"project\":\"%s\",\"format\":\"%s\"}", + project, formats[i]); + char *resp = cbm_mcp_handle_tool(srv, "query_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "NoSuchLabel")); + ASSERT_NOT_NULL(strstr(resp, "NO_SUCH_TYPE")); + free(resp); + } + + cbm_mcp_server_free(srv); + PASS(); +} + +/* T12 (fragility audit 2026-07-18, ISSUE-2): the same unobserved label + * referenced in multiple patterns or UNION branches must be named ONCE in + * the hint, not once per occurrence — "Klass, Klass." reads as a bug to + * the calling model even though it is cosmetic. */ +TEST(query_graph_hint_dedupes_repeated_unknown_names) { + const char *project = "hint_dedup_probe"; + + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/hint-dedup-probe"), CBM_STORE_OK); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), + "{\"query\":\"MATCH (a:Klass) MATCH (b:Klass) RETURN a UNION " + "MATCH (a:Klass) RETURN a\",\"project\":\"%s\"}", + project); + char *resp = cbm_mcp_handle_tool(srv, "query_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Klass")); + /* Exactly one mention: "Klass, Klass" is the red condition. */ + ASSERT_NULL(strstr(resp, "Klass, Klass")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* T13 (edge-case review 2026-07-18): hint_walk_where_exists_types / + * hint_walk_expr_exists_types probe edge types named inside EXISTS { ... } + * predicates in WHERE (and, via post_with_where, a WITH...WHERE tail) — + * previously untested. An unobserved type referenced only this way must + * still be named in the zero-row hint. */ +TEST(query_graph_hint_names_unknown_type_in_exists_predicate) { + const char *project = "hint_exists_predicate_probe"; + + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/hint-exists-predicate-probe"), + CBM_STORE_OK); + cbm_node_t fn = {.project = project, + .label = "Function", + .name = "f", + .qualified_name = "hint_exists_predicate_probe.f", + .file_path = "f.py"}; + ASSERT_GT(cbm_store_upsert_node(store, &fn), 0); + cbm_store_close(store); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[CBM_SZ_512]; + /* Plain (not "NOT") EXISTS: with zero NONEXISTENT_TYPE edges in the + * store, this predicate is false for every row, so the query itself + * returns zero rows — the condition the hint path needs to fire. */ + snprintf(args, sizeof(args), + "{\"query\":\"MATCH (f:Function) WHERE EXISTS { (f)-[:NONEXISTENT_TYPE]->() } " + "RETURN f.name\",\"project\":\"%s\"}", + project); + char *resp = cbm_mcp_handle_tool(srv, "query_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "NONEXISTENT_TYPE")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(server_default_mode_shows_streamlined_tools) { + /* New server default is streamlined mode unless CBM_TOOL_MODE/config opts + * into classic. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/list\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "search_graph")); + ASSERT_NOT_NULL(strstr(resp, "query_graph")); + ASSERT_NOT_NULL(strstr(resp, "search_code")); + ASSERT_NOT_NULL(strstr(resp, "trace_path")); + ASSERT_NOT_NULL(strstr(resp, "get_code")); + /* The deleted mega-tool must NOT appear */ + ASSERT_NULL(strstr(resp, "search_code_graph")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(api_surface_default_streamlined_regression_gate) { + char *saved_mode = save_tool_mode(); + cbm_unsetenv("CBM_TOOL_MODE"); + + char *json = cbm_mcp_tools_list(NULL); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(json); + + /* Five user-facing tools plus the _hidden_tools discovery hint. */ + ASSERT_EQ(6, tool_list_exact_count(json)); + ASSERT(tool_list_has_exact_name(json, "search_graph")); + ASSERT(tool_list_has_exact_name(json, "query_graph")); + ASSERT(tool_list_has_exact_name(json, "search_code")); + ASSERT(tool_list_has_exact_name(json, "trace_path")); + ASSERT(tool_list_has_exact_name(json, "get_code")); + ASSERT(tool_list_has_exact_name(json, "_hidden_tools")); + + ASSERT(!tool_list_has_exact_name(json, "index_repository")); + ASSERT(!tool_list_has_exact_name(json, "get_code_snippet")); + ASSERT(!tool_list_has_exact_name(json, "get_architecture")); + ASSERT(!tool_list_has_exact_name(json, "index_dependencies")); + ASSERT(!tool_list_has_exact_name(json, "search_code_graph")); + free(json); + PASS(); +} + +TEST(api_surface_classic_regression_gate) { + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + + char *json = cbm_mcp_tools_list(NULL); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(json); + + ASSERT_EQ(16, tool_list_exact_count(json)); + ASSERT(tool_list_has_exact_name(json, "index_repository")); + ASSERT(tool_list_has_exact_name(json, "search_graph")); + ASSERT(tool_list_has_exact_name(json, "query_graph")); + ASSERT(tool_list_has_exact_name(json, "trace_path")); + ASSERT(tool_list_has_exact_name(json, "get_code_snippet")); + ASSERT(tool_list_has_exact_name(json, "get_graph_schema")); + ASSERT(tool_list_has_exact_name(json, "get_architecture")); + ASSERT(tool_list_has_exact_name(json, "search_code")); + ASSERT(tool_list_has_exact_name(json, "list_projects")); + ASSERT(tool_list_has_exact_name(json, "delete_project")); + ASSERT(tool_list_has_exact_name(json, "index_status")); + ASSERT(tool_list_has_exact_name(json, "check_index_coverage")); + ASSERT(tool_list_has_exact_name(json, "detect_changes")); + ASSERT(tool_list_has_exact_name(json, "manage_adr")); + ASSERT(tool_list_has_exact_name(json, "ingest_traces")); + ASSERT(tool_list_has_exact_name(json, "index_dependencies")); + + ASSERT(!tool_list_has_exact_name(json, "get_code")); + ASSERT(!tool_list_has_exact_name(json, "_hidden_tools")); + ASSERT(!tool_list_has_exact_name(json, "search_code_graph")); + free(json); + PASS(); +} + +TEST(tool_mode_config_switches_live_server_surface) { + char *saved_mode = save_tool_mode(); + cbm_unsetenv("CBM_TOOL_MODE"); + + char *tmp = th_mktempdir("cbm_tool_mode_live"); + ASSERT_NOT_NULL(tmp); + cbm_config_t *server_cfg = cbm_config_open(tmp); + cbm_config_t *writer_cfg = cbm_config_open(tmp); + ASSERT_NOT_NULL(server_cfg); + ASSERT_NOT_NULL(writer_cfg); + ASSERT_EQ(cbm_config_set(writer_cfg, "tool_mode", "streamlined"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, server_cfg); + + char *streamlined = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(streamlined); + ASSERT_EQ(6, tool_list_exact_count(streamlined)); + ASSERT(tool_list_has_exact_name(streamlined, "get_code")); + ASSERT(!tool_list_has_exact_name(streamlined, "index_repository")); + free(streamlined); + + /* A separate config connection models `config set tool_mode classic` + * while the MCP process remains alive. The next tools/list must read the + * persisted value rather than a startup-only cache. */ + ASSERT_EQ(cbm_config_set(writer_cfg, "tool_mode", "classic"), 0); + char *classic = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(classic); + ASSERT_EQ(16, tool_list_exact_count(classic)); + ASSERT(tool_list_has_exact_name(classic, "index_repository")); + ASSERT(!tool_list_has_exact_name(classic, "get_code")); + free(classic); + + ASSERT_EQ(cbm_config_set(writer_cfg, "tool_mode", "streamlined"), 0); + streamlined = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(streamlined); + ASSERT_EQ(6, tool_list_exact_count(streamlined)); + free(streamlined); + + cbm_mcp_server_free(srv); + cbm_config_close(writer_cfg); + cbm_config_close(server_cfg); + th_rmtree(tmp); + restore_tool_mode(saved_mode); + PASS(); +} + +TEST(hidden_tools_reveal_discoverable_tools) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *before = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(before); + ASSERT_EQ(6, tool_list_exact_count(before)); + ASSERT(!tool_list_has_exact_name(before, "index_repository")); + ASSERT(!tool_list_has_exact_name(before, "get_architecture")); + free(before); + + char *hint = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(hint); + ASSERT_NOT_NULL(strstr(hint, "revealed")); + free(hint); + + char *after = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(after); + ASSERT(tool_list_has_exact_name(after, "index_repository")); + ASSERT(tool_list_has_exact_name(after, "get_code_snippet")); + ASSERT(tool_list_has_exact_name(after, "get_architecture")); + ASSERT(tool_list_has_exact_name(after, "index_dependencies")); + ASSERT(tool_list_has_exact_name(after, "trace_path")); + ASSERT(tool_list_has_exact_name(after, "_hidden_tools")); + ASSERT(tool_list_has_exact_name(after, "check_index_coverage")); + ASSERT_EQ(18, tool_list_exact_count(after)); + free(after); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(codex_client_initial_catalog_exposes_advanced_tools) { + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "streamlined", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Version is intentionally not a compatibility boundary until Codex has a + * verified first-fixed release or MCP exposes a client refresh capability. */ + char *initialize = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"codex-mcp-client\",\"version\":\"1.2.3\"}}}"); + ASSERT_NOT_NULL(initialize); + free(initialize); + + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + ASSERT(tool_list_has_exact_name(tools, "search_graph")); + ASSERT(tool_list_has_exact_name(tools, "get_code")); + ASSERT(tool_list_has_exact_name(tools, "check_index_coverage")); + ASSERT(tool_list_has_exact_name(tools, "index_repository")); + ASSERT(tool_list_has_exact_name(tools, "delete_project")); + ASSERT(tool_list_has_exact_name(tools, "_hidden_tools")); + ASSERT_EQ(18, tool_list_exact_count(tools)); + free(tools); + + char *hint = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(hint); + char *text = extract_tool_text(hint); + ASSERT_NOT_NULL(text); + ASSERT(json_array_has_string(text, "already_visible_tools", "check_index_coverage")); + ASSERT(!json_array_has_string(text, "hidden_tools", "check_index_coverage")); + free(text); + free(hint); + + cbm_mcp_server_free(srv); + restore_tool_mode(saved_mode); + PASS(); +} + +TEST(non_codex_client_initial_catalog_remains_streamlined) { + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "streamlined", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *initialize = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"codex-mcp-client-compatible\"," + "\"version\":\"1.0\"}}}"); + ASSERT_NOT_NULL(initialize); + free(initialize); + + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + ASSERT_EQ(6, tool_list_exact_count(tools)); + ASSERT(!tool_list_has_exact_name(tools, "check_index_coverage")); + ASSERT(!tool_list_has_exact_name(tools, "index_repository")); + free(tools); + + cbm_mcp_server_free(srv); + restore_tool_mode(saved_mode); + PASS(); +} + +TEST(hidden_tools_payload_excludes_already_visible_configured_tools) { + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "streamlined", 1); + + char *tmp = th_mktempdir("cbm_hidden_tools_cfg"); + ASSERT_NOT_NULL(tmp); + char cfg_dir[CBM_SZ_512]; + int n = snprintf(cfg_dir, sizeof(cfg_dir), "%s", tmp); + ASSERT_TRUE(n > 0 && (size_t)n < sizeof(cfg_dir)); + + cbm_config_t *cfg = cbm_config_open(cfg_dir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, "tool_index_repository", "true"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + char *before = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(before); + ASSERT(tool_list_has_exact_name(before, "index_repository")); + ASSERT(!tool_list_has_exact_name(before, "get_architecture")); + free(before); + + char *hint = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(hint); + char *text = extract_tool_text(hint); + ASSERT_NOT_NULL(text); + ASSERT(json_array_has_string(text, "advanced_tools", "index_repository")); + ASSERT(json_array_has_string(text, "already_visible_tools", "index_repository")); + ASSERT(!json_array_has_string(text, "hidden_tools", "index_repository")); + ASSERT(json_array_has_string(text, "hidden_tools", "get_architecture")); + free(text); + free(hint); + + char *after = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(after); + ASSERT(tool_list_has_exact_name(after, "get_architecture")); + ASSERT(tool_list_has_exact_name(after, "check_index_coverage")); + ASSERT_EQ(18, tool_list_exact_count(after)); + free(after); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + restore_tool_mode(saved_mode); + PASS(); +} + +TEST(streamlined_reveal_covers_classic_capabilities) { + char *saved_mode = save_tool_mode(); + + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + char *classic = cbm_mcp_tools_list(NULL); + cbm_unsetenv("CBM_TOOL_MODE"); + ASSERT_NOT_NULL(classic); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *hint = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(hint); + free(hint); + + char *revealed = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(revealed); + + const char *classic_tools[] = { + "index_repository", "search_graph", "query_graph", "trace_path", + "get_code_snippet", "get_graph_schema", "get_architecture", + "search_code", "list_projects", "delete_project", "index_status", + "check_index_coverage", "detect_changes", "manage_adr", "ingest_traces", + "index_dependencies", + }; + for (size_t i = 0; i < sizeof(classic_tools) / sizeof(classic_tools[0]); i++) { + ASSERT(tool_list_has_exact_name(classic, classic_tools[i])); + ASSERT(tool_list_has_exact_name(revealed, classic_tools[i])); + } + + /* Streamlined keeps get_code as the concise source-retrieval spelling, but + * reveal also exposes get_code_snippet for full upstream/classic parity. */ + ASSERT(tool_list_has_exact_name(revealed, "get_code")); + ASSERT(tool_list_has_exact_name(revealed, "_hidden_tools")); + ASSERT(!tool_list_has_exact_name(classic, "get_code")); + ASSERT(!tool_list_has_exact_name(classic, "_hidden_tools")); + + free(revealed); + cbm_mcp_server_free(srv); + free(classic); + restore_tool_mode(saved_mode); + PASS(); +} + +TEST(query_graph_input_schema_identical_across_modes) { + char *saved_mode = save_tool_mode(); + + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + char *classic = cbm_mcp_tools_list(NULL); + cbm_unsetenv("CBM_TOOL_MODE"); + char *streamlined = cbm_mcp_tools_list(NULL); + + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(classic); + ASSERT_NOT_NULL(streamlined); + ASSERT(tool_input_schemas_equal(classic, streamlined, "query_graph")); + + free(streamlined); + free(classic); + PASS(); +} + +TEST(streamlined_core_parameter_contract) { + char *saved_mode = save_tool_mode(); + cbm_unsetenv("CBM_TOOL_MODE"); + + char *json = cbm_mcp_tools_list(NULL); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(json); + + const char *search_params[] = { + "project", "label", "name_pattern", "pattern", "qn_pattern", + "query", "file_pattern", "semantic_query", "relationship", + "case_sensitive", "min_degree", "max_degree", "exclude_entry_points", + "include_connected", "limit", "offset", "sort_by", "mode", "summary", + "compact", "include_dependencies", "exclude", + }; + for (size_t i = 0; i < sizeof(search_params) / sizeof(search_params[0]); i++) { + ASSERT(tool_schema_has_property(json, "search_graph", search_params[i])); + } + + const char *query_params[] = { + "query", "project", "max_rows", "max_output_bytes", "graph", "format", + }; + for (size_t i = 0; i < sizeof(query_params) / sizeof(query_params[0]); i++) { + ASSERT(tool_schema_has_property(json, "query_graph", query_params[i])); + } + ASSERT(tool_schema_required_has(json, "query_graph", "query")); + + const char *trace_params[] = { + "function_name", "qualified_name", "project", "direction", "depth", + "max_results", "compact", "mode", "edge_types", "exclude", + "include_tests", "risk_labels", "parameter_name", + }; + for (size_t i = 0; i < sizeof(trace_params) / sizeof(trace_params[0]); i++) { + ASSERT(tool_schema_has_property(json, "trace_path", trace_params[i])); + } + ASSERT(!tool_schema_has_property(json, "trace_path", "scope")); + ASSERT(!tool_schema_required_has(json, "trace_path", "function_name")); + ASSERT(!tool_schema_required_has(json, "trace_path", "project")); + + const char *code_params[] = { + "qualified_name", "project", "mode", "max_lines", "auto_resolve", + "include_neighbors", "compact", + }; + for (size_t i = 0; i < sizeof(code_params) / sizeof(code_params[0]); i++) { + ASSERT(tool_schema_has_property(json, "get_code", code_params[i])); + } + ASSERT(tool_schema_required_has(json, "get_code", "qualified_name")); + ASSERT_NOT_NULL(strstr(json, "compact config key")); + + const char *source_params[] = { + "pattern", "project", "file_pattern", "path_filter", "regex", + "case_sensitive", "context", "mode", "limit", + }; + for (size_t i = 0; i < sizeof(source_params) / sizeof(source_params[0]); i++) { + ASSERT(tool_schema_has_property(json, "search_code", source_params[i])); + } + + free(json); + PASS(); +} + +TEST(default_tool_autoindex_description_is_precise) { + char *json = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(json); + + ASSERT_NOT_NULL(strstr(json, "Default tools auto-index")); + ASSERT_NOT_NULL( + strstr(json, "search_code resolves its project through the same auto-indexing path")); + ASSERT_NOT_NULL(strstr(json, "Auto-indexes the project on first use when enabled")); + ASSERT_NOT_NULL(strstr(json, "using the same project resolver as the graph tools")); + ASSERT_NOT_NULL(strstr(json, "Use file_pattern to narrow traversal")); + ASSERT_NOT_NULL(strstr(json, "anchored literal file regexes")); + ASSERT_NULL(strstr(json, "Does not index projects")); + ASSERT_NULL(strstr(json, "INSTEAD OF")); + + free(json); + + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + char *classic = cbm_mcp_tools_list(NULL); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(classic); + ASSERT_NULL(strstr(classic, "INSTEAD OF")); + free(classic); + + PASS(); +} + +TEST(query_graph_description_explains_compositional_value) { + char *streamlined = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(streamlined); + ASSERT_NOT_NULL(strstr(streamlined, "Core compositional tool; use it early")); + ASSERT_NOT_NULL( + strstr(streamlined, "Create new, effective, computationally efficient custom Cypher")); + ASSERT_NOT_NULL(strstr(streamlined, "Non-exhaustive examples")); + ASSERT_NOT_NULL(strstr(streamlined, "Any supported query shape is allowed")); + ASSERT_NOT_NULL(strstr(streamlined, "multiple projected fields or aliases")); + ASSERT_NOT_NULL(strstr(streamlined, "WHERE n.project =~")); + ASSERT_NULL(strstr(streamlined, "ORDER BY CASE")); + ASSERT_NOT_NULL(strstr(streamlined, "WITH can feed later MATCH/OPTIONAL MATCH stages")); + ASSERT_NOT_NULL(strstr(streamlined, "multi-hop paths")); + ASSERT_NOT_NULL(strstr(streamlined, "aggregates/hotspots")); + ASSERT_NOT_NULL(strstr(streamlined, "LIMIT are optional efficiency aids")); + ASSERT_NOT_NULL(strstr(streamlined, "Maximum result rows")); + ASSERT_NOT_NULL(strstr(streamlined, "remain exact before this output cap")); + ASSERT_NOT_NULL(strstr(streamlined, "can lower but not bypass the cap")); + ASSERT_NULL(strstr(streamlined, "limits nodes scanned")); + free(streamlined); + + /* query_graph is serialized from the same canonical definition in both + * modes; this guards the user-facing value contract as well as schema parity. */ + char *saved_mode = save_tool_mode(); + cbm_setenv("CBM_TOOL_MODE", "classic", 1); + char *classic = cbm_mcp_tools_list(NULL); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(classic); + ASSERT_NOT_NULL(strstr(classic, "Core compositional tool; use it early")); + ASSERT_NOT_NULL( + strstr(classic, "Create new, effective, computationally efficient custom Cypher")); + ASSERT_NOT_NULL(strstr(classic, "Non-exhaustive examples")); + ASSERT_NOT_NULL(strstr(classic, "Any supported query shape is allowed")); + ASSERT_NOT_NULL(strstr(classic, "multiple projected fields or aliases")); + ASSERT_NOT_NULL(strstr(classic, "WHERE n.project =~")); + ASSERT_NULL(strstr(classic, "ORDER BY CASE")); + ASSERT_NOT_NULL(strstr(classic, "WITH can feed later MATCH/OPTIONAL MATCH stages")); + ASSERT_NOT_NULL(strstr(classic, "Maximum result rows")); + ASSERT_NOT_NULL(strstr(classic, "remain exact before this output cap")); + ASSERT_NOT_NULL(strstr(classic, "can lower but not bypass the cap")); + ASSERT_NULL(strstr(classic, "limits nodes scanned")); + free(classic); + + PASS(); +} + +TEST(revealed_trace_path_parameter_contract) { + char *saved_mode = save_tool_mode(); + cbm_unsetenv("CBM_TOOL_MODE"); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *hint = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(hint); + free(hint); + + char *json = cbm_mcp_tools_list(srv); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(json); + + const char *trace_params[] = { + "function_name", "qualified_name", "project", "direction", "depth", + "max_results", "compact", "mode", "edge_types", "exclude", + "include_tests", "risk_labels", "parameter_name", + }; + for (size_t i = 0; i < sizeof(trace_params) / sizeof(trace_params[0]); i++) { + ASSERT(tool_schema_has_property(json, "trace_path", trace_params[i])); + } + ASSERT(!tool_schema_has_property(json, "trace_path", "scope")); + + free(json); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(revealed_advanced_tool_schema_matches_handlers) { + char *saved_mode = save_tool_mode(); + cbm_unsetenv("CBM_TOOL_MODE"); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *hint = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(hint); + free(hint); + + char *json = cbm_mcp_tools_list(srv); + restore_tool_mode(saved_mode); + ASSERT_NOT_NULL(json); + + ASSERT(tool_schema_has_property(json, "get_code_snippet", "compact")); + ASSERT(tool_schema_has_property(json, "get_architecture", "exclude")); + ASSERT(!tool_schema_required_has(json, "get_graph_schema", "project")); + ASSERT(!tool_schema_required_has(json, "get_architecture", "project")); + ASSERT_NOT_NULL(strstr(json, "Graph edge creation from traces is not yet implemented")); + ASSERT(tool_schema_has_property(json, "detect_changes", "direction")); + ASSERT(tool_schema_has_property(json, "detect_changes", "limit")); + ASSERT(tool_schema_has_property(json, "detect_changes", "format")); + ASSERT_NOT_NULL(strstr(json, "one multi-source graph traversal")); + ASSERT_NULL(strstr(json, "Reserved for future multi-hop impact traversal")); + + free(json); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 2. Dispatch tests ────────────────────────────────────── */ + +TEST(search_graph_dispatch) { + /* §4b: search_graph is now a default-surface tool and dispatches directly + * to handle_search_graph (previously reached via the search_code_graph + * mega-tool's default branch). */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"nonexistent_xyz\"}"); + ASSERT_NOT_NULL(result); + /* Should get a response (may be empty results, not an error about unknown tool) */ + ASSERT_NULL(strstr(result, "unknown tool")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(query_graph_dispatch) { + /* §4b: query_graph is now a default-surface tool and dispatches directly + * to handle_query_graph (previously reached via search_code_graph cypher=). */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "query_graph", + "{\"query\":\"MATCH (n) RETURN n.name LIMIT 1\"}"); + ASSERT_NOT_NULL(result); + /* Should get a Cypher response (may be empty), not unknown tool error */ + ASSERT_NULL(strstr(result, "unknown tool")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(get_code_dispatch) { + /* get_code → routes to get_code_snippet handler */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "get_code", + "{\"qualified_name\":\"nonexistent.func\"}"); + ASSERT_NOT_NULL(result); + /* Should get snippet response (may be not found), not unknown tool */ + ASSERT_NULL(strstr(result, "unknown tool")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(canonical_tool_names_dispatch) { + /* Canonical streamlined/classic tool names should dispatch directly. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* search_graph */ + char *r1 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(r1); + ASSERT_NULL(strstr(r1, "unknown tool")); + free(r1); + + /* query_graph */ + char *r2 = cbm_mcp_handle_tool(srv, "query_graph", + "{\"query\":\"MATCH (n) RETURN n.name LIMIT 1\"}"); + ASSERT_NOT_NULL(r2); + ASSERT_NULL(strstr(r2, "unknown tool")); + free(r2); + + /* get_code_snippet */ + char *r3 = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"test.func\"}"); + ASSERT_NOT_NULL(r3); + ASSERT_NULL(strstr(r3, "unknown tool")); + free(r3); + + /* trace_path */ + char *r4 = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"main\"}"); + ASSERT_NOT_NULL(r4); + ASSERT_NULL(strstr(r4, "unknown tool")); + free(r4); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 3. Project param path support ────────────────────────── */ + +TEST(project_param_path_detection) { + /* expand_project_param should detect paths and convert. + * §4b: test indirectly via search_graph (same handler the old + * search_code_graph default branch routed to) with a path-like project. + * Since the path won't exist as a db, we just verify no crash. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"/tmp/nonexistent_test_project\",\"name_pattern\":\"foo\"}"); + ASSERT_NOT_NULL(result); + /* Should get an error about project not loaded, not a crash */ + ASSERT_NOT_NULL(strstr(result, "error") != NULL ? strstr(result, "error") : result); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 4. Edge case tests ───────────────────────────────────── */ + +TEST(unknown_tool_returns_error) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "completely_fake_tool", "{}"); + ASSERT_NOT_NULL(result); + /* Should indicate unknown tool */ + ASSERT_NOT_NULL(strstr(result, "unknown")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(null_tool_name_returns_error) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, NULL, "{}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "missing")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 5. Progressive disclosure ────────────────────────────── */ + +TEST(streamlined_mode_has_hidden_tools_hint) { + /* Streamlined tool list should include _hidden_tools entry + * that tells the AI what tools are available and how to enable them. + * The persisted config path changes a live shared daemon. A client-process + * environment override cannot replace an already-running daemon's + * environment, so it must not be advertised as the equivalent default. */ + char *json = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(json); + ASSERT_NOT_NULL(strstr(json, "_hidden_tools")); + ASSERT_NOT_NULL(strstr(json, "index_repository")); + ASSERT_NOT_NULL(strstr(json, "config set tool_mode classic")); + ASSERT_NULL(strstr(json, "set env CBM_TOOL_MODE=classic")); + free(json); + PASS(); +} + +TEST(hidden_tools_still_dispatch) { + /* Even though hidden in streamlined mode, calling hidden tool names + * still works — dispatch is unconditional. This ensures the AI can + * use hidden tools after learning about them from the hint. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* index_status is hidden in streamlined mode but should still dispatch */ + char *result = cbm_mcp_handle_tool(srv, "index_status", "{}"); + ASSERT_NOT_NULL(result); + /* Should get a response about no project, not unknown tool */ + ASSERT_NULL(strstr(result, "unknown")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 6. Session context in responses ─────────────────────── */ + +TEST(search_graph_has_session_project) { + /* search_graph response should include session_project */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "test_proj"); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"nonexistent\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "session_project")); + ASSERT_NOT_NULL(strstr(result, "test_proj")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(cli_session_detection_uses_cwd_project_slug) { + char old_cwd[CBM_PATH_MAX]; + ASSERT_NOT_NULL(getcwd(old_cwd, sizeof(old_cwd))); + + char *repo = th_mktempdir("cbm_cli_session"); + ASSERT_NOT_NULL(repo); + char repo_path[CBM_PATH_MAX]; + int repo_n = snprintf(repo_path, sizeof(repo_path), "%s", repo); + ASSERT_GT(repo_n, 0); + ASSERT((size_t)repo_n < sizeof(repo_path)); + char *expected_project = cbm_project_name_from_path(repo_path); + ASSERT_NOT_NULL(expected_project); + + char *cfg_dir = th_mktempdir("cbm_cli_session_cfg"); + ASSERT_NOT_NULL(cfg_dir); + cbm_config_t *cfg = cbm_config_open(cfg_dir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "false"), 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_config(srv, cfg); + + ASSERT_EQ(chdir(repo_path), 0); + cbm_mcp_server_detect_session(srv); + ASSERT_EQ(chdir(old_cwd), 0); + + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"cbm_cli_session_unindexed\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "session_project")); + ASSERT_NOT_NULL(strstr(result, expected_project)); + free(result); + + cbm_mcp_server_free(srv); + cbm_config_close(cfg); + free(expected_project); + th_cleanup(cfg_dir); + th_cleanup(repo_path); + PASS(); +} + +TEST(search_graph_slug_project_sets_session_context) { + /* A fresh CLI/MCP server may be called with project= from + * list_projects. Results already came from that DB, but the first response + * context used to stay empty unless project was passed as a filesystem path. */ + const char *proj = "_tc_ctx_slug_"; + char *root_path = th_mktempdir("cbm_tc_ctx_slug"); + ASSERT_NOT_NULL(root_path); + char db_path[CBM_SZ_1K]; + int npath = snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), proj); + ASSERT_GT(npath, 0); + ASSERT((size_t)npath < sizeof(db_path)); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, proj, root_path), CBM_STORE_OK); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = "tc_ctx_slug_fn", + .qualified_name = "_tc_ctx_slug_.tc_ctx_slug_fn", + .file_path = "src/tc_ctx_slug.c"}; + ASSERT_GT(cbm_store_upsert_node(s, &n), 0); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* format=json: this test pins the legacy JSON "nodes":1 count shape; + * default_response_format is toon. */ + char *result = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"_tc_ctx_slug_\",\"name_pattern\":\"tc_ctx_slug_fn\",\"limit\":1," + "\"format\":\"json\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "session_project")); + ASSERT_NOT_NULL(strstr(result, "_tc_ctx_slug_")); + ASSERT_NOT_NULL(strstr(result, "\\\"nodes\\\":1")); + free(result); + + result = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"_tc_ctx_slug_missing_\",\"name_pattern\":\"tc_ctx_slug_fn\",\"limit\":1}"); + ASSERT_NOT_NULL(result); + ASSERT(strstr(result, "error") != NULL || strstr(result, "not found") != NULL || + strstr(result, "not_found") != NULL); + free(result); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + th_cleanup(root_path); + PASS(); +} + +TEST(search_graph_explicit_project_context_uses_resolved_store_project) { + const char *session_proj = "_tc_ctx_session_"; + const char *target_proj = "_tc_ctx_explicit_"; + char *target_root = th_mktempdir("cbm_tc_ctx_explicit"); + ASSERT_NOT_NULL(target_root); + + char cargo_toml[CBM_SZ_1K]; + int n = snprintf(cargo_toml, sizeof(cargo_toml), "%s/Cargo.toml", target_root); + ASSERT_GT(n, 0); + ASSERT((size_t)n < sizeof(cargo_toml)); + ASSERT_EQ(th_write_file(cargo_toml, "[package]\nname = \"ctx-target\"\nversion = \"0.1.0\"\n"), + 0); + + char db_path[CBM_SZ_1K]; + n = snprintf(db_path, sizeof(db_path), "%s/%s.db", cbm_resolve_cache_dir(), target_proj); + ASSERT_GT(n, 0); + ASSERT((size_t)n < sizeof(db_path)); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, target_proj, target_root), CBM_STORE_OK); + cbm_node_t function = {.project = target_proj, + .label = "Function", + .name = "tc_ctx_explicit_fn", + .qualified_name = "_tc_ctx_explicit_.tc_ctx_explicit_fn", + .file_path = "src/lib.rs"}; + cbm_node_t structure = {.project = target_proj, + .label = "Struct", + .name = "TcCtxExplicit", + .qualified_name = "_tc_ctx_explicit_.TcCtxExplicit", + .file_path = "src/lib.rs"}; + ASSERT_GT(cbm_store_upsert_node(s, &function), 0); + ASSERT_GT(cbm_store_upsert_node(s, &structure), 0); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, session_proj); + char *result = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"_tc_ctx_explicit_\"," + "\"name_pattern\":\"tc_ctx_explicit_fn\",\"limit\":1,\"format\":\"json\"}"); + ASSERT_NOT_NULL(result); + + /* session_project identifies the server CWD. The one-shot context must + * separately identify and summarize the explicit project whose store + * supplied the results. */ + ASSERT_NOT_NULL(strstr(result, session_proj)); + ASSERT_NOT_NULL(strstr(result, "\\\"project\\\":\\\"_tc_ctx_explicit_\\\"")); + ASSERT_NOT_NULL(strstr(result, "\\\"nodes\\\":2")); + ASSERT_NOT_NULL(strstr(result, "\\\"label\\\":\\\"Function\\\"")); + ASSERT_NOT_NULL(strstr(result, "\\\"label\\\":\\\"Struct\\\"")); + ASSERT_NOT_NULL(strstr(result, "\\\"detected_ecosystem\\\":\\\"cargo\\\"")); + free(result); + + cbm_mcp_server_free(srv); + + /* BM25 with an explicitly requested JSON format has its own early return. + * It must deliver the same first-response context as graph-mode JSON. */ + srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, session_proj); + result = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"_tc_ctx_explicit_\"," + "\"query\":\"tc_ctx_explicit_fn\",\"limit\":1,\"format\":\"json\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, session_proj)); + ASSERT_NOT_NULL(strstr(result, "\\\"project\\\":\\\"_tc_ctx_explicit_\\\"")); + ASSERT_NOT_NULL(strstr(result, "\\\"nodes\\\":2")); + ASSERT_NOT_NULL(strstr(result, "\\\"detected_ecosystem\\\":\\\"cargo\\\"")); + free(result); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + th_cleanup(target_root); + PASS(); +} + +TEST(index_status_has_session_project) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "my_proj"); + char *result = cbm_mcp_handle_tool(srv, "index_status", "{}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "session_project")); + ASSERT_NOT_NULL(strstr(result, "my_proj")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 7. Context injection ─────────────────────────────────── */ + +TEST(first_response_has_context_header) { + /* First search_graph call should include _context with schema/status. + * Uses in-memory store (no session_root) so auto-index won't trigger. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "ctx_test"); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(result); + /* First response should have _context (escaped: inner JSON is JSON-encoded in outer) */ + ASSERT_NOT_NULL(strstr(result, "\\\"_context\\\":")); + ASSERT_NOT_NULL(strstr(result, "status")); + free(result); + + /* Second call should NOT have _context (already injected). + * Use escaped pattern "\\\"_context\\\":" to avoid false-positives from node + * names like "inject_context_once" that contain "_context" as a substring. */ + char *result2 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test2\"}"); + ASSERT_NOT_NULL(result2); + ASSERT_NULL(strstr(result2, "\\\"_context\\\":")); + /* But session_project should still be present */ + ASSERT_NOT_NULL(strstr(result2, "session_project")); + free(result2); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(context_has_schema_info) { + /* _context should include node_labels and edge_types arrays. + * format=json: pins the legacy JSON _context shape; default_response_format + * is toon, which delivers the same facts as native _context_* TOON fields. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(result); + /* In-memory store has schema tables → should see these fields (escaped JSON key) */ + ASSERT_NOT_NULL(strstr(result, "\\\"_context\\\":")); + ASSERT_NOT_NULL(strstr(result, "node_labels")); + ASSERT_NOT_NULL(strstr(result, "edge_types")); + free(result); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 7. MCP Resources tests (Phase 10) ───────────────────── */ + +TEST(resources_list_returns_3_resources) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/list\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "codebase://schema")); + ASSERT_NOT_NULL(strstr(resp, "codebase://architecture")); + ASSERT_NOT_NULL(strstr(resp, "codebase://status")); + ASSERT_NOT_NULL(strstr(resp, "Code Graph Schema")); + ASSERT_NOT_NULL(strstr(resp, "Architecture Overview")); + ASSERT_NOT_NULL(strstr(resp, "Index Status")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_schema) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://schema\"}}"); + ASSERT_NOT_NULL(resp); + /* Response should contain contents array with schema data */ + ASSERT_NOT_NULL(strstr(resp, "contents")); + ASSERT_NOT_NULL(strstr(resp, "codebase://schema")); + ASSERT_NOT_NULL(strstr(resp, "application/json")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_architecture) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://architecture\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "contents")); + ASSERT_NOT_NULL(strstr(resp, "codebase://architecture")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_status) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "contents")); + ASSERT_NOT_NULL(strstr(resp, "codebase://status")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_unknown_uri) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://nonexistent\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "error")); + /* MCP spec: resource not found = -32002 */ + ASSERT_NOT_NULL(strstr(resp, "-32002")); + /* Error message should include the bad URI and list valid resources */ + ASSERT_NOT_NULL(strstr(resp, "codebase://nonexistent")); + ASSERT_NOT_NULL(strstr(resp, "codebase://schema")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(initialize_advertises_resources_capability) { + char *resp = cbm_mcp_initialize_response(NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "resources")); + ASSERT_NOT_NULL(strstr(resp, "listChanged")); + free(resp); + PASS(); +} + +TEST(initialize_parses_client_resources_capability) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Send initialize with client capabilities including resources */ + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{\"resources\":{\"subscribe\":false}}," + "\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* MCP resources are pull-only — declaring resources capability does NOT mean + * the client auto-reads codebase://schema or codebase://architecture. + * inject_context_once must still fire on the first tool call so the model + * receives architectural context without requiring explicit user action. + * Ref: https://modelcontextprotocol.io/specification/2025-06-18/server/resources */ + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\"}"); + ASSERT_NOT_NULL(result); + /* session_project should still appear */ + ASSERT_NOT_NULL(strstr(result, "session_project")); + /* _context MUST appear on first call regardless of resources capability. + * cbm_mcp_text_result embeds inner JSON as a JSON-encoded string value, so + * "\"_context\":" in the inner JSON appears as "\\\"_context\\\":" in raw bytes. */ + ASSERT_NOT_NULL(strstr(result, "\\\"_context\\\":")); + free(result); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(no_resources_capability_gets_context_injection) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Send initialize WITHOUT resources capability */ + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"old-client\",\"version\":\"1.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* Without resources capability, first tool call should get _context */ + char *result = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "\\\"_context\\\":")); + free(result); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 8. MCP spec compliance tests ─────────────────────────── */ + +TEST(initialize_response_has_protocol_version) { + char *resp = cbm_mcp_initialize_response(NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "protocolVersion")); + /* Default (no params): returns latest supported version */ + ASSERT_NOT_NULL(strstr(resp, "2025-11-25")); + ASSERT_NOT_NULL(strstr(resp, "serverInfo")); + ASSERT_NOT_NULL(strstr(resp, "codebase-memory-mcp")); + free(resp); + PASS(); +} + +TEST(initialize_resources_cap_subscribe_false) { + /* Server must advertise subscribe:false (we don't support per-resource subscriptions) */ + char *resp = cbm_mcp_initialize_response(NULL); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"subscribe\":false")); + ASSERT_NOT_NULL(strstr(resp, "\"listChanged\":true")); + free(resp); + PASS(); +} + +TEST(resources_list_has_mimeType_and_description) { + /* MCP spec requires name, uri; recommends description and mimeType */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/list\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "mimeType")); + ASSERT_NOT_NULL(strstr(resp, "application/json")); + ASSERT_NOT_NULL(strstr(resp, "description")); + ASSERT_NOT_NULL(strstr(resp, "name")); + /* Resource descriptions should be actionable — tell AI when to read them */ + ASSERT_NOT_NULL(strstr(resp, "Read this")); + ASSERT_NOT_NULL(strstr(resp, "Cypher")); /* schema mentions Cypher */ + ASSERT_NOT_NULL(strstr(resp, "PageRank")); /* architecture mentions PageRank */ + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_response_has_contents_array) { + /* MCP spec: resources/read returns {contents: [{uri, mimeType, text}]} */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"contents\"")); + ASSERT_NOT_NULL(strstr(resp, "\"uri\"")); + ASSERT_NOT_NULL(strstr(resp, "\"mimeType\"")); + ASSERT_NOT_NULL(strstr(resp, "\"text\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_missing_uri_param) { + /* resources/read with no uri → error -32602 (invalid params) */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\"," + "\"params\":{}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "error")); + ASSERT_NOT_NULL(strstr(resp, "Missing uri")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resources_read_no_params_at_all) { + /* resources/read with no params object */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "error")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 9. Client behavioral difference tests ───────────────── */ + +TEST(resource_client_gets_context_only_on_first_call) { + /* Resource-capable client gets _context on the FIRST call only. + * MCP resources are pull-only (no server push). Declaring resources:{} + * does not trigger automatic resource reads — the model must be explicitly + * instructed or the user must @-mention a resource URI. + * context_injected=true after first injection prevents duplicates. + * Ref: https://modelcontextprotocol.io/docs/concepts/resources */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{\"resources\":{}}," + "\"clientInfo\":{\"name\":\"modern\",\"version\":\"2.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* First call MUST have _context (JSON key "_context":). + * cbm_mcp_text_result embeds inner JSON as a JSON-encoded string value, so the + * literal bytes in the outer response are \"_context\": (backslash-escaped quotes). + * Search for "\\\"_context\\\":" which matches \"_context\": in raw bytes. */ + char *r1 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(r1); + ASSERT_NOT_NULL(strstr(r1, "\\\"_context\\\":")); + ASSERT_NOT_NULL(strstr(r1, "session_project")); + free(r1); + + /* Calls 2 and 3: _context must NOT repeat (context_injected dedup guard). + * Use the escaped pattern "\\\"_context\\\":" to avoid false positives from + * node names like "inject_context_once" matching bare "_context" searches. */ + for (int i = 0; i < 2; i++) { + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NULL(strstr(r, "\\\"_context\\\":")); + ASSERT_NOT_NULL(strstr(r, "session_project")); + free(r); + } + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(legacy_client_gets_context_only_on_first_call) { + /* Legacy client: _context on first call, NOT on subsequent calls */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{}," + "\"clientInfo\":{\"name\":\"legacy\",\"version\":\"1.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* First call: MUST have _context (escaped: inner JSON is JSON-encoded in outer) */ + char *r1 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(r1); + ASSERT_NOT_NULL(strstr(r1, "\\\"_context\\\":")); + free(r1); + + /* Second call: must NOT have _context (one-shot dedup via context_injected flag) */ + char *r2 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test2\"}"); + ASSERT_NOT_NULL(r2); + ASSERT_NULL(strstr(r2, "\\\"_context\\\":")); + ASSERT_NOT_NULL(strstr(r2, "session_project")); + free(r2); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(empty_resources_capability_still_gets_context) { + /* MCP spec: capabilities.resources:{} means resources supported + * (neither subscribe nor listChanged, but resources protocol works). + * Even so, resources are pull-only — declaring support does not trigger + * automatic reads. _context injection applies to ALL clients. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{\"resources\":{}}," + "\"clientInfo\":{\"name\":\"minimal\",\"version\":\"1.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* Empty resources:{} client still gets _context on first call. + * Use escaped pattern: inner JSON is embedded as JSON-encoded string in outer response. */ + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\\\"_context\\\":")); + free(r); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(no_initialize_defaults_to_legacy_behavior) { + /* Server with no initialize call → defaults to legacy (no resources) */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Call tool directly without initialize → should get _context (legacy). + * format=json: pins the legacy JSON _context shape; default_response_format + * is toon, which delivers the same facts as native _context_* TOON fields. */ + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\",\"format\":\"json\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\\\"_context\\\":")); + free(r); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 17. MCP resources pull-only: inject_context_once fires for ALL clients ─ + * + * The MCP spec defines resources as "application-controlled" — there is no + * server-push mechanism. When a client declares resources capability, it means + * the client CAN fetch resources explicitly (e.g. via @resource-uri in Claude + * Code), NOT that it will automatically read them. References: + * https://modelcontextprotocol.io/specification/2025-06-18/server/resources + * https://modelcontextprotocol.io/docs/concepts/resources + * https://workos.com/blog/mcp-features-guide (resources = application-controlled) + * + * inject_context_once embeds schema/architecture in the FIRST tool response. + * This is the only reliable delivery channel that doesn't require explicit + * user action: + * - notifications/resources/updated signals changes but sends NO content + * - resources/read requires explicit model action (not automatic) + * - Claude Code resources require user @-mention or explicit instruction + * + * The context_injected flag already prevents duplicate injection on subsequent + * calls, so ALL clients receive context exactly once regardless of whether + * they declared resources capability. + * ──────────────────────────────────────────────────────────────────────── */ + +TEST(resource_capable_client_gets_context_on_first_call) { + /* Resource-capable client MUST get _context on the first tool call. + * Declaring resources:{} does NOT mean automatic resource reads — + * the model only reads resources when explicitly instructed (user @-mention + * or system prompt directive). Embedding _context in the first response + * is the only reliable delivery channel without user intervention. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{\"resources\":{}}," + "\"clientInfo\":{\"name\":\"claude-code\",\"version\":\"2.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* First tool call MUST include _context regardless of resources capability. + * Escaped pattern: cbm_mcp_text_result embeds inner JSON as a JSON-encoded string, + * so "\"_context\":" appears as "\\\"_context\\\":" in the outer raw bytes. */ + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"test\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "\\\"_context\\\":")); + free(r); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_capable_client_no_context_on_second_call) { + /* After first-call injection, context_injected=true suppresses duplicates. + * This dedup applies to ALL clients equally — resource-capable or not. + * Session-project is still included on every call (it's lightweight). */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2024-11-05\"," + "\"capabilities\":{\"resources\":{\"subscribe\":true}}," + "\"clientInfo\":{\"name\":\"claude-code\",\"version\":\"2.0\"}}}"); + ASSERT_NOT_NULL(resp); + free(resp); + + /* First call: _context present (escaped pattern — inner JSON is JSON-encoded in outer) */ + char *r1 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\"}"); + ASSERT_NOT_NULL(r1); + ASSERT_NOT_NULL(strstr(r1, "\\\"_context\\\":")); + free(r1); + + /* Second call: _context must NOT be repeated (context_injected guard). + * "\\\"_context\\\":" searches for \"_context\": in raw bytes — won't false-positive + * on node names like "inject_context_once". */ + char *r2 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"y\"}"); + ASSERT_NOT_NULL(r2); + ASSERT_NULL(strstr(r2, "\\\"_context\\\":")); + /* session_project must still be present on every call */ + ASSERT_NOT_NULL(strstr(r2, "session_project")); + free(r2); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 10. Tool-resource cross-referencing tests ───────────── */ + +TEST(tool_descriptions_reference_resources) { + /* Tool descriptions should tell the AI about available resources + * so it knows to read codebase://schema before writing Cypher, etc. + * §4b: trace_path mentions codebase://architecture; the _hidden_tools + * hint mentions all three resource URIs. */ + char *json = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(json); + /* Resources are referenced in the default surface / hint */ + ASSERT_NOT_NULL(strstr(json, "codebase://schema")); + ASSERT_NOT_NULL(strstr(json, "codebase://architecture")); + /* get_code should reference search_graph for qualified names */ + ASSERT_NOT_NULL(strstr(json, "search_graph")); + free(json); + PASS(); +} + +TEST(hidden_tools_hint_mentions_resources) { + /* The _hidden_tools progressive disclosure hint should tell the AI + * about context resources so it can read them without enabling tools */ + char *json = cbm_mcp_tools_list(NULL); + ASSERT_NOT_NULL(json); + ASSERT_NOT_NULL(strstr(json, "_hidden_tools")); + /* Should mention all 3 resource URIs */ + ASSERT_NOT_NULL(strstr(json, "codebase://schema")); + ASSERT_NOT_NULL(strstr(json, "codebase://architecture")); + ASSERT_NOT_NULL(strstr(json, "codebase://status")); + free(json); + PASS(); +} + +/* ── 11. Error message quality tests ─────────────────────── */ + +TEST(error_no_project_loaded_has_hint) { + /* search_graph with a nonexistent project name → resolve_store returns NULL + * but cbm_mcp_server_new creates a default store. Use a project name that + * won't match any DB file to trigger the error. The REQUIRE_STORE macro + * in search_graph handles auto-index, but for a fake project path it will + * still fail and return the hint. Test via the error structure in trace. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* trace_path goes through REQUIRE_STORE → no project loaded if store NULL. + * With cbm_mcp_server_new(NULL), resolve_store(NULL) returns the default store. + * The function_not_found error (which also has hint) tests the pattern. */ + char *r = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"nonexistent_fn\"}"); + ASSERT_NOT_NULL(r); + /* The response should have a hint field (either "no project loaded" or "not found") */ + ASSERT_NOT_NULL(strstr(r, "hint")); + free(r); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(error_function_not_found_includes_name) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *r = cbm_mcp_handle_tool(srv, "trace_path", + "{\"function_name\":\"nonexistent_xyz_func\"}"); + ASSERT_NOT_NULL(r); + /* Error should include the function name that was searched for */ + ASSERT_NOT_NULL(strstr(r, "nonexistent_xyz_func")); + ASSERT_NOT_NULL(strstr(r, "hint")); + free(r); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(error_symbol_not_found_includes_qn) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *r = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"nonexistent.module.func_xyz\"}"); + ASSERT_NOT_NULL(r); + /* Error should include the qualified name that was searched for */ + ASSERT_NOT_NULL(strstr(r, "nonexistent.module.func_xyz")); + ASSERT_NOT_NULL(strstr(r, "hint")); + free(r); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(error_missing_required_param_has_hint) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* query_graph missing query param */ + char *r1 = cbm_mcp_handle_tool(srv, "query_graph", "{}"); + ASSERT_NOT_NULL(r1); + ASSERT_NOT_NULL(strstr(r1, "query is required")); + ASSERT_NOT_NULL(strstr(r1, "hint")); + free(r1); + + /* trace_path missing function_name */ + char *r2 = cbm_mcp_handle_tool(srv, "trace_path", "{}"); + ASSERT_NOT_NULL(r2); + ASSERT_NOT_NULL(strstr(r2, "function_name or qualified_name is required")); + ASSERT_NOT_NULL(strstr(r2, "hint")); + free(r2); + + /* get_code_snippet missing qualified_name */ + char *r3 = cbm_mcp_handle_tool(srv, "get_code_snippet", "{}"); + ASSERT_NOT_NULL(r3); + ASSERT_NOT_NULL(strstr(r3, "qualified_name is required")); + ASSERT_NOT_NULL(strstr(r3, "hint")); + free(r3); + + /* search_code missing pattern */ + char *r4 = cbm_mcp_handle_tool(srv, "search_code", "{}"); + ASSERT_NOT_NULL(r4); + ASSERT_NOT_NULL(strstr(r4, "pattern is required")); + ASSERT_NOT_NULL(strstr(r4, "hint")); + free(r4); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(error_unknown_tool_lists_valid_tools) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *r = cbm_mcp_handle_tool(srv, "nonexistent_tool_xyz", "{}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "nonexistent_tool_xyz")); + ASSERT_NOT_NULL(strstr(r, "hint")); + /* §4b: hint now lists the split tools, not the deleted mega-tool */ + ASSERT_NOT_NULL(strstr(r, "search_graph")); + ASSERT_NOT_NULL(strstr(r, "tools/list")); + free(r); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(error_resource_not_found_has_spec_code) { + /* MCP spec: resource not found = -32002 with actionable message */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://bad_uri_xyz\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "-32002")); + ASSERT_NOT_NULL(strstr(resp, "bad_uri_xyz")); + ASSERT_NOT_NULL(strstr(resp, "codebase://schema")); + ASSERT_NOT_NULL(strstr(resp, "resources/list")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 12. JSON-RPC response structure tests (e2e) ─────────── */ + +TEST(resource_error_is_top_level_not_nested_in_result) { + /* BUG found by binary testing: resource errors were double-wrapped. + * handle_resources_read returned a pre-formatted JSON-RPC error, but + * cbm_mcp_server_handle wrapped it again in cbm_jsonrpc_format_response. + * Result: {result: {jsonrpc, id:0, error: {...}}} instead of {error: {...}} + * Fix: error path returns early before the wrapper. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://nonexistent\"}}"); + ASSERT_NOT_NULL(resp); + /* Must have top-level "error" key, NOT nested inside "result" */ + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NULL(strstr(resp, "\"result\"")); + /* Error id must match request id */ + ASSERT_NOT_NULL(strstr(resp, "\"id\":42")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_error_missing_uri_is_top_level) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"resources/read\"," + "\"params\":{}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NULL(strstr(resp, "\"result\"")); + ASSERT_NOT_NULL(strstr(resp, "\"id\":99")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_error_no_params_is_top_level) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"resources/read\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"error\"")); + ASSERT_NULL(strstr(resp, "\"result\"")); + ASSERT_NOT_NULL(strstr(resp, "\"id\":77")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_success_has_result_not_error) { + /* Complement: successful reads must have "result", NOT "error" */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"result\"")); + ASSERT_NOT_NULL(strstr(resp, "\"id\":50")); + ASSERT_NOT_NULL(strstr(resp, "contents")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_schema_returns_real_data_when_indexed) { + /* After search_graph opens the session store, resources should return real data. + * Uses cbm_mcp_server_new(NULL) which creates an in-memory store. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Force store open via a tool call */ + char *r1 = cbm_mcp_handle_tool(srv, "search_graph", + "{\"name_pattern\":\"x\"}"); + free(r1); + /* Now read schema resource — should have node_labels/edge_types arrays */ + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://schema\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "contents")); + /* text field should have node_labels (may be empty array but key must exist) */ + ASSERT_NOT_NULL(strstr(resp, "node_labels")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_status_returns_not_indexed_when_no_store) { + /* Fresh server with no session — status resource should say not_indexed */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Don't set session_project, don't call any tools */ + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "contents")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(resource_status_and_architecture_report_dirty_metadata) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *s = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(s); + const char *proj = "_tc_resource_dirty_"; + cbm_mcp_server_set_project(srv, proj); + ASSERT_EQ(cbm_store_upsert_project(s, proj, "/tmp/resource-dirty"), CBM_STORE_OK); + + cbm_node_t node = {.project = proj, + .label = "Function", + .name = "ResourceRun", + .qualified_name = "_tc_resource_dirty_.ResourceRun", + .file_path = "resource.c"}; + ASSERT_GT(cbm_store_upsert_node(s, &node), 0); + + cbm_dirty_file_state_t dirty = {.project = proj, + .rel_path = "resource.c", + .observed_hash = "resource-dirty-hash", + .observed_generation = 15, + .source = CBM_STORE_DIRTY_SOURCE_EXPLICIT_REINDEX, + .status = CBM_STORE_DIRTY_STATUS_PENDING}; + ASSERT_EQ(cbm_store_upsert_dirty_file(s, &dirty), CBM_STORE_OK); + + char *status_resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://status\"}}"); + ASSERT_NOT_NULL(status_resp); + ASSERT_NOT_NULL(strstr(status_resp, "dirty_files_pending")); + ASSERT_NOT_NULL( + strstr(status_resp, "Canonical counts exclude pending dirty-file graph changes")); + ASSERT_NOT_NULL(strstr(status_resp, "overlay_read_view")); + free(status_resp); + + char *arch_resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"resources/read\"," + "\"params\":{\"uri\":\"codebase://architecture\"}}"); + ASSERT_NOT_NULL(arch_resp); + ASSERT_NOT_NULL(strstr(arch_resp, "dirty_files_pending")); + ASSERT_NOT_NULL(strstr(arch_resp, "codebase://architecture reads canonical graph summaries")); + free(arch_resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 13. Dep search bug regression tests ─────────────────── */ + +/* Bug 1: resolve_store must route dep project names to parent DB. + * "myapp.dep.pandas" should open myapp.db, not myapp.dep.pandas.db. */ +TEST(dep_search_explicit_dep_project_name) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Use unique name to avoid creating DB files that interfere with other tests */ + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_deptest_proj_.dep.pandas\",\"name_pattern\":\".*\",\"limit\":1}"); + ASSERT_NOT_NULL(r); + free(r); + /* Clean up any DB file that resolve_store may have created */ + char path[1024]; + snprintf(path, sizeof(path), "%s/_tc_deptest_proj_.db", + cbm_resolve_cache_dir()); + (void)cbm_unlink(path); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Bug 2: Store prefix match — search with project name must include deps. */ +TEST(store_prefix_match_includes_deps) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "myapp", "/tmp/myapp"); + cbm_store_upsert_project(s, "myapp.dep.lib", "/tmp/lib"); + cbm_node_t n1 = {.project = "myapp", .label = "Function", .name = "main", + .qualified_name = "myapp.main", .file_path = "main.c"}; + cbm_store_upsert_node(s, &n1); + cbm_node_t n2 = {.project = "myapp.dep.lib", .label = "Function", .name = "lib_fn", + .qualified_name = "myapp.dep.lib.lib_fn", .file_path = "lib.c"}; + cbm_store_upsert_node(s, &n2); + cbm_search_params_t params = {0}; + params.project = "myapp"; + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_TRUE(out.count >= 2); + bool found_project = false, found_dep = false; + for (int i = 0; i < out.count; i++) { + if (strcmp(out.results[i].node.project, "myapp") == 0) found_project = true; + if (strcmp(out.results[i].node.project, "myapp.dep.lib") == 0) found_dep = true; + } + ASSERT_TRUE(found_project); + ASSERT_TRUE(found_dep); + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + +/* #18 RED: project-over-dependency source ranking in the COMMON prefix-match + * path (project="myapp", which includes myapp.dep.*). A dependency symbol must + * NOT outrank the project's own symbol of the same name — the "Path" concern + * (a python-stdlib Path must not be front-of-line over the user's own Path). + * + * The store already has a dep-last tiebreak, but it ONLY fires for + * params->project_pattern (glob). The common prefix path sets params->project, + * so today deps are NOT demoted here → a dep inserted first (lower id) wins the + * name/id tiebreak and ranks above the project symbol. This test must FAIL until + * dep-last is extended to the prefix-match path. */ +TEST(store_prefix_ranks_project_above_dep) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "myapp", "/tmp/myapp"); + cbm_store_upsert_project(s, "myapp.dep.stdlib", "/tmp/stdlib"); + + /* Insert the DEPENDENCY symbol FIRST so it gets the lower id — under the + * current (no-dep-last-in-prefix-path) ordering it wins the id tiebreak. */ + cbm_node_t nd = {.project = "myapp.dep.stdlib", .label = "Class", .name = "Path", + .qualified_name = "myapp.dep.stdlib.Path", .file_path = "stdlib/path.py"}; + cbm_store_upsert_node(s, &nd); + cbm_node_t np = {.project = "myapp", .label = "Class", .name = "Path", + .qualified_name = "myapp.Path", .file_path = "src/path.py"}; + cbm_store_upsert_node(s, &np); + + cbm_search_params_t params = {0}; + params.project = "myapp"; /* prefix match: includes myapp.dep.* */ + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_GTE(out.count, 2); + + /* The PROJECT 'Path' must rank above the DEPENDENCY 'Path'. */ + int proj_idx = -1, dep_idx = -1; + for (int i = 0; i < out.count; i++) { + if (strcmp(out.results[i].node.name, "Path") != 0) continue; + if (strcmp(out.results[i].node.project, "myapp") == 0) proj_idx = i; + if (strcmp(out.results[i].node.project, "myapp.dep.stdlib") == 0) dep_idx = i; + } + ASSERT_NEQ(proj_idx, -1); + ASSERT_NEQ(dep_idx, -1); + ASSERT_TRUE(proj_idx < dep_idx); /* project before dependency */ + + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + +/* #49: dep-last ranking holds across ALL sort_by modes (relevance/name/degree/ + * calls/linkrank), not just the default. With equal primary metrics (both + * "Path", no edges → tied pagerank/degree/calls/linkrank/name), the dep-last + * secondary key must put the project symbol first in every mode. Dep is + * inserted first (lower id) so a missing dep-last would let the dep win the id + * tiebreak — making this a real per-mode check, not a tautology. */ +TEST(store_prefix_ranks_project_above_dep_all_sort_modes) { + static const char *modes[] = {"relevance", "name", "degree", "calls", "linkrank"}; + for (size_t m = 0; m < sizeof(modes) / sizeof(modes[0]); m++) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "myapp", "/tmp/myapp"); + cbm_store_upsert_project(s, "myapp.dep.stdlib", "/tmp/stdlib"); + cbm_node_t nd = {.project = "myapp.dep.stdlib", .label = "Class", .name = "Path", + .qualified_name = "myapp.dep.stdlib.Path", .file_path = "stdlib/path.py"}; + cbm_store_upsert_node(s, &nd); /* dep first → lower id */ + cbm_node_t np = {.project = "myapp", .label = "Class", .name = "Path", + .qualified_name = "myapp.Path", .file_path = "src/path.py"}; + cbm_store_upsert_node(s, &np); + + cbm_search_params_t params = {0}; + params.project = "myapp"; /* prefix: includes deps */ + params.sort_by = modes[m]; + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_GTE(out.count, 2); + + int proj_idx = -1, dep_idx = -1; + for (int i = 0; i < out.count; i++) { + if (strcmp(out.results[i].node.name, "Path") != 0) continue; + if (strcmp(out.results[i].node.project, "myapp") == 0) proj_idx = i; + if (strcmp(out.results[i].node.project, "myapp.dep.stdlib") == 0) dep_idx = i; + } + ASSERT_NEQ(proj_idx, -1); + ASSERT_NEQ(dep_idx, -1); + ASSERT_TRUE(proj_idx < dep_idx); /* project before dep in EVERY mode */ + + cbm_store_search_free(&out); + cbm_store_close(s); + } + PASS(); +} + +/* #38: the dep-last ranking is tunable. With disable_dep_ranking=true the store + * applies PURE relevance order — a dep symbol may rank above the project's own. + * This makes the ranking a parameter (config key search_disable_dep_ranking) + * rather than a hard-coded decision, per the project's meta-param conventions. */ +TEST(store_prefix_disable_dep_ranking_lets_dep_win) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "myapp", "/tmp/myapp"); + cbm_store_upsert_project(s, "myapp.dep.stdlib", "/tmp/stdlib"); + cbm_node_t nd = {.project = "myapp.dep.stdlib", .label = "Class", .name = "Path", + .qualified_name = "myapp.dep.stdlib.Path", .file_path = "stdlib/path.py"}; + cbm_store_upsert_node(s, &nd); + cbm_node_t np = {.project = "myapp", .label = "Class", .name = "Path", + .qualified_name = "myapp.Path", .file_path = "src/path.py"}; + cbm_store_upsert_node(s, &np); + + cbm_search_params_t params = {0}; + params.project = "myapp"; + params.disable_dep_ranking = true; /* pure relevance — no dep demotion */ + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_GTE(out.count, 2); + + /* With dep-ranking disabled and equal relevance, the dep (lower id, inserted + * first) wins the id tiebreak → ranks above the project symbol. */ + int proj_idx = -1, dep_idx = -1; + for (int i = 0; i < out.count; i++) { + if (strcmp(out.results[i].node.name, "Path") != 0) continue; + if (strcmp(out.results[i].node.project, "myapp") == 0) proj_idx = i; + if (strcmp(out.results[i].node.project, "myapp.dep.stdlib") == 0) dep_idx = i; + } + ASSERT_NEQ(proj_idx, -1); + ASSERT_NEQ(dep_idx, -1); + ASSERT_TRUE(dep_idx < proj_idx); /* dep before project (ranking disabled) */ + + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + +/* Bug 2 complement: exact match should NOT include deps. */ +TEST(store_exact_match_excludes_deps) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "myapp", "/tmp/myapp"); + cbm_store_upsert_project(s, "myapp.dep.lib", "/tmp/lib"); + cbm_node_t n1 = {.project = "myapp", .label = "Function", .name = "main", + .qualified_name = "myapp.main", .file_path = "main.c"}; + cbm_store_upsert_node(s, &n1); + cbm_node_t n2 = {.project = "myapp.dep.lib", .label = "Function", .name = "lib_fn", + .qualified_name = "myapp.dep.lib.lib_fn", .file_path = "lib.c"}; + cbm_store_upsert_node(s, &n2); + cbm_search_params_t params = {0}; + params.project = "myapp"; + params.project_exact = true; + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_EQ(out.count, 1); + ASSERT_STR_EQ(out.results[0].node.project, "myapp"); + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + +/* Bug 3: cbm_is_dep_project must detect deps from any project. */ +TEST(is_dep_project_cross_project_detection) { + ASSERT_TRUE(cbm_is_dep_project("otherapp.dep.pandas", "myapp")); + ASSERT_TRUE(cbm_is_dep_project("otherapp.dep.serde", "myapp")); + ASSERT_TRUE(cbm_is_dep_project("myapp.dep.pandas", "myapp")); + ASSERT_FALSE(cbm_is_dep_project("myapp", "myapp")); + ASSERT_FALSE(cbm_is_dep_project("otherapp", "myapp")); + ASSERT_FALSE(cbm_is_dep_project("deputy", "myapp")); + PASS(); +} + +/* E2E: Full dep workflow — index + deps + search returns both with correct tags. */ +TEST(e2e_dep_search_returns_project_and_dep_results) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "app", "/tmp/app"); + cbm_store_upsert_project(s, "app.dep.mylib", "/tmp/lib"); + cbm_node_t n1 = {.project = "app", .label = "Function", .name = "app_main", + .qualified_name = "app.app_main", .file_path = "main.c"}; + cbm_store_upsert_node(s, &n1); + cbm_node_t n2 = {.project = "app.dep.mylib", .label = "Function", .name = "lib_helper", + .qualified_name = "app.dep.mylib.lib_helper", .file_path = "lib.c"}; + cbm_store_upsert_node(s, &n2); + cbm_search_params_t params = {0}; + params.project = "app"; + params.limit = 10; + cbm_search_output_t out = {0}; + cbm_store_search(s, ¶ms, &out); + ASSERT_EQ(out.count, 2); + bool found_dep = false, found_proj = false; + for (int i = 0; i < out.count; i++) { + if (cbm_is_dep_project(out.results[i].node.project, "app")) { + found_dep = true; + const char *sep = strstr(out.results[i].node.project, ".dep."); + ASSERT_NOT_NULL(sep); + ASSERT_STR_EQ(sep + 5, "mylib"); + } else { + found_proj = true; + } + } + ASSERT_TRUE(found_dep); + ASSERT_TRUE(found_proj); + cbm_store_search_free(&out); + cbm_store_close(s); + PASS(); +} + +/* ── 14. MCP protocol conformance (binary-level) ─────────── */ + +TEST(all_tools_have_object_inputSchema) { + /* BUG found by dogfooding: _hidden_tools had inputSchema as a JSON string + * instead of a JSON object. Claude Code rejected the entire tools/list, + * making all 3 real tools invisible. MCP spec requires inputSchema to be + * a JSON Schema object, not a serialized string. + * This test parses the tools/list JSON and verifies every tool's + * inputSchema is a JSON object (not string, not null, not array). */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + ASSERT_NOT_NULL(resp); + + /* Parse the response and check each tool */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *result = yyjson_obj_get(root, "result"); + ASSERT_NOT_NULL(result); + yyjson_val *tools = yyjson_obj_get(result, "tools"); + ASSERT_NOT_NULL(tools); + ASSERT_TRUE(yyjson_is_arr(tools)); + + size_t idx, max; + yyjson_val *tool; + yyjson_arr_foreach(tools, idx, max, tool) { + yyjson_val *name = yyjson_obj_get(tool, "name"); + yyjson_val *schema = yyjson_obj_get(tool, "inputSchema"); + const char *tool_name = yyjson_get_str(name); + /* inputSchema MUST be a JSON object, NOT a string */ + ASSERT_NOT_NULL(schema); + ASSERT_TRUE(yyjson_is_obj(schema)); /* fails if string/null/array */ + (void)tool_name; /* used for debugging if assertion fails */ + } + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 15. Cross-project search prefix collision tests ──────── */ + +TEST(cross_project_search_not_confused_by_prefix) { + /* A session project and a longer target project can share a path-derived + * name prefix. Prefix-only matching opened the session DB instead of the + * requested target DB. + * Fix: after strncmp, check next char is '.' or '\0'. + * + * Test: create server with session "myapp", search with project "myapp-other". + * The search should NOT use the session store — it should try to open + * "myapp-other.db" (which won't exist, giving 0 results or error), + * NOT return session store data. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + + /* Search with a project that shares prefix but is NOT a dep of session */ + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"myapp-other-project\",\"name_pattern\":\".*\",\"limit\":3}"); + ASSERT_NOT_NULL(r); + /* Should NOT return session_project data (the bug returned session results). + * The response should indicate the OTHER project (may be empty or error). */ + /* Key check: if the bug exists, session store is used and we'd see results + * from "myapp" project. With the fix, resolve_store opens "myapp-other-project.db" + * which either doesn't exist (error/empty) or has different data. */ + free(r); + + /* Clean up any spurious DB file created by resolve_store */ + char path[1024]; + snprintf(path, sizeof(path), "%s/myapp-other-project.db", + cbm_resolve_cache_dir()); + (void)cbm_unlink(path); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(session_dep_search_uses_session_store) { + /* Complement: "myapp.dep.lib" SHOULD use session store (myapp.db). + * The '.' after session prefix correctly identifies it as a dep. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + + /* This should use session store (myapp.db), not open myapp.dep.lib.db */ + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"myapp.dep.lib\",\"name_pattern\":\".*\",\"limit\":3}"); + ASSERT_NOT_NULL(r); + /* We can't easily verify which DB was opened, but the search shouldn't crash + * and should return session_project in the response. */ + ASSERT_NOT_NULL(strstr(r, "session_project")); + free(r); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(exact_session_name_uses_session_store) { + /* Searching with exact session project name should use session store. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"myapp\",\"name_pattern\":\".*\",\"limit\":3}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "session_project")); + free(r); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* Edge cases for prefix collision — various naming patterns that could match */ + +TEST(prefix_collision_dash_after_session_name) { + /* "myapp-v2" should NOT match session "myapp" — dash is not a dep separator */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"myapp-v2\",\"name_pattern\":\".*\",\"limit\":1}"); + ASSERT_NOT_NULL(r); + free(r); + char path[1024]; + snprintf(path, sizeof(path), "%s/myapp-v2.db", cbm_resolve_cache_dir()); + (void)cbm_unlink(path); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(prefix_collision_underscore_after_session_name) { + /* "myapp_test" should NOT match session "myapp" */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"myapp_test\",\"name_pattern\":\".*\",\"limit\":1}"); + ASSERT_NOT_NULL(r); + free(r); + char path[1024]; + snprintf(path, sizeof(path), "%s/myapp_test.db", cbm_resolve_cache_dir()); + (void)cbm_unlink(path); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(prefix_collision_longer_name_with_dot_not_dep) { + /* "myapp.config" has a dot but is NOT a dep (no ".dep." segment). + * Should NOT use session store — it's a different project. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"myapp.config\",\"name_pattern\":\".*\",\"limit\":1}"); + ASSERT_NOT_NULL(r); + free(r); + /* Note: "myapp.config" starts with "myapp" + "." so the DB selection + * WILL use session store (by design — the check is session + "."). + * This is acceptable because deps use ".dep." which contains ".", + * and non-dep sub-projects (myapp.config) would be in the same DB. */ + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(prefix_collision_completely_different_project) { + /* "other-project" shares no prefix with session "myapp" */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "myapp"); + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"other-project\",\"name_pattern\":\".*\",\"limit\":1}"); + ASSERT_NOT_NULL(r); + free(r); + char path[1024]; + snprintf(path, sizeof(path), "%s/other-project.db", cbm_resolve_cache_dir()); + (void)cbm_unlink(path); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(prefix_collision_session_is_substring_of_project) { + /* Session "ab" and project "abc" — "ab" is a prefix of "abc" but + * "abc"[2] is 'c' (not '.' or '\0'), so should NOT match. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_session_project(srv, "ab"); + char *r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"abc\",\"name_pattern\":\".*\",\"limit\":1}"); + ASSERT_NOT_NULL(r); + free(r); + char path[1024]; + snprintf(path, sizeof(path), "%s/abc.db", cbm_resolve_cache_dir()); + (void)cbm_unlink(path); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── 16. get_code NULL-project regression tests ─────────── */ + +/* Bug: Tier 1-3 use WHERE project = ?1, so they return nothing when project + * is NULL (SQL NULL comparison is always false). Fix: eff_project falls back + * to srv->current_project when the caller omits the project param. + * + * Test: after search_graph opens a store, get_code with no project param + * should resolve via Tier 1 exact QN match. */ +TEST(get_code_no_project_uses_open_store_tier1) { + /* Create a file DB with one node */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_gc_proj_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_gc_proj_", "/tmp"); + cbm_node_t n = {.project = "_tc_gc_proj_", .label = "Function", + .name = "tc_resolve_fn", + .qualified_name = "_tc_gc_proj_.src.tc_resolve_fn", + .file_path = "src/tc_resolve_fn.c"}; + cbm_store_upsert_node(s, &n); + cbm_store_close(s); + + /* Create server; call search_graph to open the store (sets current_project) */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *sr = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_gc_proj_\",\"name_pattern\":\"tc_resolve_fn\",\"limit\":1}"); + ASSERT_NOT_NULL(sr); + free(sr); + + /* get_code with no project param — eff_project must fall back to current_project */ + char *gr = cbm_mcp_handle_tool(srv, "get_code", + "{\"qualified_name\":\"_tc_gc_proj_.src.tc_resolve_fn\"}"); + ASSERT_NOT_NULL(gr); + /* Must NOT be ambiguous — Tier 1 exact QN should resolve via eff_project */ + ASSERT_NULL(strstr(gr, "\"ambiguous\"")); + /* Must contain the function name in the response */ + ASSERT_NOT_NULL(strstr(gr, "tc_resolve_fn")); + free(gr); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +/* Bug: Tier 4 fuzzy search finding exactly 1 result returned status=ambiguous. + * Fix: when fuzzy_count == 1, resolve immediately instead of calling + * snippet_suggestions which always sets status=ambiguous. */ +TEST(get_code_single_fuzzy_result_resolves_not_ambiguous) { + /* Create a file DB with one node */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_gc_fuzzy_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_gc_fuzzy_", "/tmp"); + cbm_node_t n = {.project = "_tc_gc_fuzzy_", .label = "Function", + .name = "tc_unique_fuzzy_fn", + .qualified_name = "_tc_gc_fuzzy_.src.tc_unique_fuzzy_fn", + .file_path = "src/tc_unique_fuzzy_fn.c"}; + cbm_store_upsert_node(s, &n); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Open the store via search_graph so current_project is set */ + char *sr = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_gc_fuzzy_\",\"name_pattern\":\"tc_unique_fuzzy_fn\",\"limit\":1}"); + ASSERT_NOT_NULL(sr); + free(sr); + + /* QN with a wrong prefix — Tiers 1-3 will miss, Tier 4 fuzzy finds 1 by name */ + char *gr = cbm_mcp_handle_tool(srv, "get_code", + "{\"qualified_name\":\"wrong.prefix.tc_unique_fuzzy_fn\"}"); + ASSERT_NOT_NULL(gr); + /* Must NOT be ambiguous — single fuzzy result should auto-resolve */ + ASSERT_NULL(strstr(gr, "\"ambiguous\"")); + /* Must contain the function name */ + ASSERT_NOT_NULL(strstr(gr, "tc_unique_fuzzy_fn")); + free(gr); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +/* Option C: cold-start test — no prior search_code_graph call. + * extract_project_from_qn() must find the DB by scanning dot-prefixes of the + * QN, so get_code works even when srv->current_project is unset. */ +TEST(get_code_cold_start_parses_project_from_qn) { + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_gc_cold_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_gc_cold_", "/tmp"); + cbm_node_t n = {.project = "_tc_gc_cold_", .label = "Function", + .name = "tc_cold_fn", + .qualified_name = "_tc_gc_cold_.src.tc_cold_fn", + .file_path = "src/tc_cold_fn.c"}; + cbm_store_upsert_node(s, &n); + cbm_store_close(s); + + /* Fresh server — no prior tool calls, srv->current_project is unset */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* get_code with no project — must parse "_tc_gc_cold_" from the QN */ + char *gr = cbm_mcp_handle_tool(srv, "get_code", + "{\"qualified_name\":\"_tc_gc_cold_.src.tc_cold_fn\"}"); + ASSERT_NOT_NULL(gr); + /* Cold-start Option C: must resolve, not return ambiguous or not-found */ + ASSERT_NULL(strstr(gr, "\"ambiguous\"")); + ASSERT_NULL(strstr(gr, "\"error\"")); + ASSERT_NOT_NULL(strstr(gr, "tc_cold_fn")); + free(gr); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +/* ── Watcher registration tests ──────────────────────────── */ + +TEST(watcher_registered_after_index_repository) { + /* Create a tiny temp repo so indexing succeeds quickly */ + char repo_path[CBM_PATH_MAX]; + int repo_len = snprintf(repo_path, sizeof(repo_path), "%s/cbm_watch_test_XXXXXX", cbm_tmpdir()); + ASSERT(repo_len >= 0 && (size_t)repo_len < sizeof(repo_path)); + ASSERT_NOT_NULL(cbm_mkdtemp(repo_path)); + char src_path[CBM_PATH_MAX]; + snprintf(src_path, sizeof(src_path), "%s/test.c", repo_path); + FILE *f = fopen(src_path, "w"); + if (f) { fprintf(f, "void hello(void) {}\n"); fclose(f); } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_watcher_t *w = cbm_watcher_new(NULL, NULL, NULL); + ASSERT_NOT_NULL(w); + cbm_mcp_server_set_watcher(srv, w); + + char args[512]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", repo_path); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + free(resp); + + ASSERT_TRUE(cbm_watcher_watch_count(w) > 0); + + cbm_mcp_server_free(srv); + cbm_watcher_free(w); + (void)cbm_unlink(src_path); + (void)cbm_rmdir(repo_path); + PASS(); +} + +TEST(watcher_registered_on_resolve_store) { + /* Pre-populate a DB with a project that has a known root_path */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_watcher_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_watcher_", "/tmp/cbm_watcher_root"); + cbm_node_t n = {.project = "_tc_watcher_", .label = "Function", + .name = "watcher_fn", .qualified_name = "_tc_watcher_.watcher_fn", + .file_path = "watcher_fn.c"}; + cbm_store_upsert_node(s, &n); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_watcher_t *w = cbm_watcher_new(NULL, NULL, NULL); + ASSERT_NOT_NULL(w); + cbm_mcp_server_set_watcher(srv, w); + + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_watcher_\",\"name_pattern\":\"watcher_fn\",\"limit\":1}"); + ASSERT_NOT_NULL(resp); + free(resp); + + ASSERT_TRUE(cbm_watcher_watch_count(w) > 0); + + cbm_mcp_server_free(srv); + cbm_watcher_free(w); + (void)cbm_unlink(db_path); + PASS(); +} + +TEST(watcher_not_registered_for_unknown_path) { + /* Project entry exists but root_path is empty — watcher must NOT be registered */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_watcher_nopath_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_watcher_nopath_", ""); + cbm_node_t n = {.project = "_tc_watcher_nopath_", .label = "Function", + .name = "nopath_fn", .qualified_name = "_tc_watcher_nopath_.nopath_fn", + .file_path = "nopath_fn.c"}; + cbm_store_upsert_node(s, &n); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_watcher_t *w = cbm_watcher_new(NULL, NULL, NULL); + ASSERT_NOT_NULL(w); + cbm_mcp_server_set_watcher(srv, w); + + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_watcher_nopath_\",\"name_pattern\":\"nopath_fn\",\"limit\":1}"); + ASSERT_NOT_NULL(resp); + free(resp); + + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + + cbm_mcp_server_free(srv); + cbm_watcher_free(w); + (void)cbm_unlink(db_path); + PASS(); +} + +/* ── Empty DB / stale index detection ────────────────────── */ + +TEST(hidden_tools_returns_info_not_error) { + /* _hidden_tools should return tool list, not "unknown tool" error */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "hidden_tools")); + ASSERT_NOT_NULL(strstr(resp, "index_repository")); + /* Must NOT be an error */ + ASSERT_NULL(strstr(resp, "unknown tool")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(compact_defaults_to_true) { + /* When compact is not provided, name field should be omitted if it's + * the last segment of qualified_name */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_compact_default_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_compact_default_", "/tmp/compact_test"); + cbm_node_t n = {.project = "_tc_compact_default_", .label = "Function", + .name = "my_func", .qualified_name = "_tc_compact_default_.my_func", + .file_path = "test.c"}; + cbm_store_upsert_node(s, &n); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* Search WITHOUT compact param — should default to compact=true */ + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_compact_default_\",\"name_pattern\":\"my_func\",\"limit\":1}"); + ASSERT_NOT_NULL(resp); + /* In compact mode, "name" should NOT appear as a separate key when + * it matches the last segment of qualified_name */ + /* Parse the result text to check */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *results = yyjson_obj_get(root, "results"); + if (results && yyjson_arr_size(results) > 0) { + yyjson_val *first = yyjson_arr_get_first(results); + /* name key should be absent in compact mode */ + ASSERT_NULL(yyjson_obj_get(first, "name")); + ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name")); + } + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +TEST(pagerank_output_has_limited_precision) { + /* Pagerank values should be serialized with limited precision (~4 sig figs), + * not full 17-digit double precision */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_pr_precision_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_pr_precision_", "/tmp/pr_test"); + cbm_node_t n1 = {.project = "_tc_pr_precision_", .label = "Function", + .name = "fn_a", .qualified_name = "_tc_pr_precision_.fn_a", + .file_path = "a.c"}; + cbm_node_t n2 = {.project = "_tc_pr_precision_", .label = "Function", + .name = "fn_b", .qualified_name = "_tc_pr_precision_.fn_b", + .file_path = "b.c"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + /* Compute PageRank (even with no edges, nodes get baseline scores) */ + cbm_pagerank_compute_default(s, "_tc_pr_precision_"); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_pr_precision_\",\"sort_by\":\"relevance\",\"limit\":2}"); + ASSERT_NOT_NULL(resp); + /* Pagerank values should NOT have more than ~8 characters (e.g. "4.72e-05") + * Check that we don't have 17-digit sequences like "0.00004717680769635863" */ + ASSERT_NULL(strstr(resp, "000000000")); /* No 9+ consecutive zeros in pagerank */ + free(resp); + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +TEST(empty_db_not_treated_as_indexed) { + /* A DB file with schema but 0 nodes should NOT prevent re-indexing. + * Regression test: previously stat(db_path)==0 was enough to skip. */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_empty_db_test_.db", + cbm_resolve_cache_dir()); + /* Create DB with schema but no data */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_close(s); + + /* Verify the file exists */ + struct stat st; + ASSERT_EQ(stat(db_path, &st), 0); + + /* Open it read-only and verify 0 nodes */ + sqlite3 *db = NULL; + ASSERT_EQ(sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL), SQLITE_OK); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM nodes", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int node_count = sqlite3_column_int(stmt, 0); + ASSERT_EQ(node_count, 0); + sqlite3_finalize(stmt); + + /* Verify "SELECT 1 FROM nodes LIMIT 1" returns no rows (this is what db_has_content checks) */ + rc = sqlite3_prepare_v2(db, "SELECT 1 FROM nodes LIMIT 1", -1, &stmt, NULL); + ASSERT_EQ(rc, SQLITE_OK); + ASSERT_NEQ(sqlite3_step(stmt), SQLITE_ROW); /* Should be SQLITE_DONE, not SQLITE_ROW */ + sqlite3_finalize(stmt); + sqlite3_close(db); + + (void)cbm_unlink(db_path); + PASS(); +} + +/* ── Exclude param tests ─────────────────────────────────── */ + +TEST(search_exclude_filters_file_paths) { + /* exclude param should remove matching results */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_exclude_test_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_exclude_test_", "/tmp/exclude_test"); + cbm_node_t n1 = {.project = "_tc_exclude_test_", .label = "Function", + .name = "core_fn", .qualified_name = "_tc_exclude_test_.core_fn", + .file_path = "src/main.c"}; + cbm_node_t n2 = {.project = "_tc_exclude_test_", .label = "Function", + .name = "test_fn", .qualified_name = "_tc_exclude_test_.test_fn", + .file_path = "tests/test_main.c"}; + cbm_node_t n3 = {.project = "_tc_exclude_test_", .label = "Function", + .name = "script_fn", .qualified_name = "_tc_exclude_test_.script_fn", + .file_path = "scripts/setup.sh"}; + cbm_store_upsert_node(s, &n1); + cbm_store_upsert_node(s, &n2); + cbm_store_upsert_node(s, &n3); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* Without exclude: should find all 3 */ + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_exclude_test_\",\"limit\":10}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "core_fn")); + ASSERT_NOT_NULL(strstr(resp, "test_fn")); + ASSERT_NOT_NULL(strstr(resp, "script_fn")); + free(resp); + + /* With exclude: should filter out tests and scripts */ + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_exclude_test_\",\"limit\":10," + "\"exclude\":[\"tests/**\",\"scripts/**\"]}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "core_fn")); + ASSERT_NULL(strstr(resp, "test_fn")); + ASSERT_NULL(strstr(resp, "script_fn")); + free(resp); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +TEST(search_exclude_empty_array_no_effect) { + /* Empty exclude array should not filter anything */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_excl_empty_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_excl_empty_", "/tmp/excl_empty"); + cbm_node_t n1 = {.project = "_tc_excl_empty_", .label = "Function", + .name = "fn1", .qualified_name = "_tc_excl_empty_.fn1", + .file_path = "src/a.c"}; + cbm_store_upsert_node(s, &n1); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_excl_empty_\",\"limit\":10,\"exclude\":[]}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "fn1")); + free(resp); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +TEST(search_exclude_all_returns_empty) { + /* Excluding everything should return 0 results, not error */ + char db_path[1024]; + snprintf(db_path, sizeof(db_path), "%s/_tc_excl_all_.db", + cbm_resolve_cache_dir()); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_excl_all_", "/tmp/excl_all"); + cbm_node_t n1 = {.project = "_tc_excl_all_", .label = "Function", + .name = "fn1", .qualified_name = "_tc_excl_all_.fn1", + .file_path = "src/a.c"}; + cbm_store_upsert_node(s, &n1); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"_tc_excl_all_\",\"limit\":10,\"exclude\":[\"**\"]}"); + ASSERT_NOT_NULL(resp); + /* Should not contain fn1 (it was excluded) and should not be an error */ + ASSERT_NULL(strstr(resp, "fn1")); + /* The response should contain "results" (empty array) not an error */ + ASSERT_NOT_NULL(strstr(resp, "results")); + free(resp); + + cbm_mcp_server_free(srv); + (void)cbm_unlink(db_path); + PASS(); +} + +TEST(exclude_param_in_tool_schema) { + /* Both streamlined and classic tool schemas should include exclude param */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *tools = cbm_mcp_tools_list(srv); + ASSERT_NOT_NULL(tools); + /* §4b: search_graph (default surface) should have exclude */ + ASSERT_NOT_NULL(strstr(tools, "\"exclude\"")); + free(tools); + cbm_mcp_server_free(srv); + PASS(); +} + +/* TDD: path_filter param (origin/main addition — fails before merge, passes after) + * origin/main mcp.c:3522–3704 adds path_filter to handle_search_code(). + * After merge, search_code schema must advertise path_filter parameter. + * Pre-merge: path_filter absent from schema → ASSERT fails (expected red). + * Post-merge: path_filter present → ASSERT passes (expected green). */ +TEST(path_filter_param_in_tool_schema) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* §4b: tools/list returns the default surface including search_code */ + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + ASSERT_NOT_NULL(resp); + /* After merge: search_code_graph (or search_code) schema must include path_filter */ + ASSERT_NOT_NULL(strstr(resp, "path_filter")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* TDD: structured error when project param AND session_project both absent + * (origin/main build_project_list_error() capability — merged per plan §2c) + * Before merge: empty/wrong result; After merge: {"error":..., "available_projects":[...]} */ +TEST(project_missing_returns_structured_error) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* No session project set, no project= param */ + char *resp = cbm_mcp_handle_tool(srv, "search_graph", "{\"name_pattern\":\"foo\"}"); + ASSERT_NOT_NULL(resp); + /* After merge with DRY project resolution: must return a valid JSON response, + * not crash. Pre-merge: may return empty results. Post-merge: structured error + * with available_projects list via build_project_list_error(). */ + ASSERT_NOT_NULL(resp); + /* At minimum, response must be valid JSON (not a bare NULL or crash) */ + bool has_error = strstr(resp, "error") != NULL; + bool has_results = strstr(resp, "results") != NULL; + bool has_project = strstr(resp, "project") != NULL; + ASSERT_TRUE(has_error || has_results || has_project); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Bug fixes: search_code/search_graph mode/param sharp edges ─ */ + +/* TDD Bug 1: case_sensitive=false must add -i to grep so uppercase patterns match lowercase. + * Before fix: build_grep_cmd has no -i flag → HELLO_WORLD misses hello_world → FAIL. + * After fix: build_grep_cmd adds -i when case_sensitive=false → match found → PASS. */ +TEST(source_grep_case_insensitive_by_default) { + /* Register a project in the store so get_project_root can resolve it */ + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/_tc_ci_test_.db", + cbm_resolve_cache_dir()); + char proj_dir[256]; + snprintf(proj_dir, sizeof(proj_dir), "%s/cbm_ci_test_%d", cbm_tmpdir(), (int)getpid()); + cbm_mkdir_p(proj_dir, 0755); + + /* Write a file with only lowercase content */ + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/hello.c", proj_dir); + FILE *f = fopen(src_path, "w"); + if (f) { fprintf(f, "void hello_world(void) {}\n"); fclose(f); } + + /* Register project in store so get_project_root returns proj_dir */ + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_ci_test_", proj_dir); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* grep for UPPERCASE pattern with case_sensitive=false (default) */ + char *resp = cbm_mcp_handle_tool(srv, "search_code", + "{\"search_in\":\"source\",\"pattern\":\"HELLO_WORLD\"," + "\"project\":\"_tc_ci_test_\",\"case_sensitive\":false}"); + ASSERT_NOT_NULL(resp); + /* After fix: -i flag → case-insensitive grep finds "hello_world" via HELLO_WORLD */ + bool found_match = strstr(resp, "hello") != NULL || strstr(resp, "hello_world") != NULL; + bool nonzero_count = strstr(resp, "\"count\":0") == NULL; + ASSERT_TRUE(found_match && nonzero_count); + free(resp); + + cbm_mcp_server_free(srv); + cbm_unlink(src_path); + cbm_rmdir(proj_dir); + (void)cbm_unlink(db_path); + PASS(); +} + +/* TDD Bug 1b: case_sensitive=true must NOT add -i → uppercase pattern misses lowercase file. + * Pattern "HELLO_WORLD" vs file containing "hello_world" only → 0 matches. */ +TEST(source_grep_case_sensitive_flag_works) { + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/_tc_cs_test_.db", + cbm_resolve_cache_dir()); + char proj_dir[256]; + snprintf(proj_dir, sizeof(proj_dir), "%s/cbm_cs_test_%d", cbm_tmpdir(), (int)getpid()); + cbm_mkdir_p(proj_dir, 0755); + + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/lower.c", proj_dir); + FILE *f = fopen(src_path, "w"); + if (f) { fprintf(f, "void hello_world(void) {}\n"); fclose(f); } + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_cs_test_", proj_dir); + cbm_store_close(s); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char *resp = cbm_mcp_handle_tool(srv, "search_code", + "{\"search_in\":\"source\",\"pattern\":\"HELLO_WORLD\"," + "\"project\":\"_tc_cs_test_\",\"case_sensitive\":true}"); + ASSERT_NOT_NULL(resp); + /* After fix: no -i flag → case-sensitive grep must NOT find "hello_world" via "HELLO_WORLD" */ + /* Accept both encodings of the zero-match count: the TOON scalar + * "total_grep_matches: 0" (the search_code compact default) and the + * escaped legacy JSON field \"total_grep_matches\":0 (format:"json"). */ + bool zero_matches = strstr(resp, "total_grep_matches: 0") != NULL || + strstr(resp, "\\\"total_grep_matches\\\":0") != NULL; + ASSERT_TRUE(zero_matches); + free(resp); + + cbm_mcp_server_free(srv); + cbm_unlink(src_path); + cbm_rmdir(proj_dir); + (void)cbm_unlink(db_path); + PASS(); +} + +/* TDD Bug 2: mode="compact" in graph mode returns an unhelpful error. + * After fix: error message must mention that "compact" belongs to source grep + * mode and explain the two separate mode enums. */ +TEST(graph_mode_compact_error_is_descriptive) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + /* mode="compact" is valid in source grep but invalid in graph mode */ + char *resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"mode\":\"compact\",\"label\":\"Function\"}"); + ASSERT_NOT_NULL(resp); + /* Must return an error (not crash or silent wrong result) */ + ASSERT_NOT_NULL(strstr(resp, "error")); + /* After fix: error should mention source grep context — "source" or "search_in" */ + bool mentions_source = strstr(resp, "source") != NULL || + strstr(resp, "search_in") != NULL || + strstr(resp, "grep") != NULL; + ASSERT_TRUE(mentions_source); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* TDD Bug 3: mode="summary" in source grep must produce a warning, + * not silently fall through to compact output. + * Before fix: response has no "mode_warning" → FAIL. + * After fix: response contains "mode_warning" field → PASS. */ +TEST(source_grep_mode_summary_warns) { + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/_tc_sm_test_.db", + cbm_resolve_cache_dir()); + char proj_dir[256]; + snprintf(proj_dir, sizeof(proj_dir), "%s/cbm_sm_test_%d", cbm_tmpdir(), (int)getpid()); + cbm_mkdir_p(proj_dir, 0755); + + char src_path[320]; + snprintf(src_path, sizeof(src_path), "%s/code.c", proj_dir); + FILE *f = fopen(src_path, "w"); + if (f) { fprintf(f, "void foo(void) {}\n"); fclose(f); } + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "_tc_sm_test_", proj_dir); + cbm_store_close(s); + + const char *requests[] = { + "{\"search_in\":\"source\",\"pattern\":\"foo\"," + "\"project\":\"_tc_sm_test_\",\"mode\":\"summary\"}", + "{\"search_in\":\"source\",\"pattern\":\"foo\"," + "\"project\":\"_tc_sm_test_\",\"mode\":\"summary\",\"format\":\"json\"}", + }; + for (size_t i = 0; i < sizeof(requests) / sizeof(requests[0]); i++) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_handle_tool(srv, "search_code", requests[i]); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "mode_warning")); + ASSERT_NOT_NULL(strstr(resp, "mode='summary' is only valid")); + free(resp); + cbm_mcp_server_free(srv); + } + cbm_unlink(src_path); + cbm_rmdir(proj_dir); + (void)cbm_unlink(db_path); + PASS(); +} + +/* TDD Bug 4 (revised for §4b): the split-tool surface eliminates the graph-vs-source + * "compact" mode collision that motivated the original "graph mode only" schema note. + * search_graph owns the compact boolean; search_code uses a mode string and has no + * compact boolean. Verify that post-§4b the compact param lives only on search_graph. */ +TEST(schema_compact_documented_as_graph_only) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + ASSERT_NOT_NULL(resp); + /* search_graph schema must include a compact boolean param */ + ASSERT_NOT_NULL(strstr(resp, "\"compact\"")); + /* The mega-tool's "graph mode only" warning is gone (no collision after split). + * search_code has no compact boolean — it uses mode instead. */ + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* TDD Bug 5: context param must appear in the search_code_graph schema. + * Before fix: schema has no "context" param → ASSERT fails (red). + * After fix: schema includes context param with description → ASSERT passes. */ +TEST(schema_has_context_param_for_source_grep) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}"); + ASSERT_NOT_NULL(resp); + /* search_code_graph schema must include "context" param */ + ASSERT_NOT_NULL(strstr(resp, "\"context\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Suite registration ──────────────────────────────────── */ + +SUITE(tool_consolidation) { + /* MCP protocol conformance */ + RUN_TEST(all_tools_have_object_inputSchema); + /* Tool visibility */ + RUN_TEST(streamlined_mode_shows_default_user_tools); + RUN_TEST(query_graph_description_repeats_current_executable_schema); + RUN_TEST(query_graph_description_uses_ready_overlay_view); + RUN_TEST(query_graph_description_populated_on_cold_start_tools_list); + RUN_TEST(query_graph_zero_row_hint_names_no_hidden_tool); + RUN_TEST(query_graph_zero_row_hint_walks_post_with_stage); + RUN_TEST(query_graph_missed_graph_hint_probes_shadow_project); + RUN_TEST(query_graph_hint_on_empty_project_no_crash_no_false_vocab); + RUN_TEST(query_graph_zero_row_hint_parity_across_formats); + RUN_TEST(query_graph_hint_dedupes_repeated_unknown_names); + RUN_TEST(query_graph_hint_names_unknown_type_in_exists_predicate); + RUN_TEST(server_default_mode_shows_streamlined_tools); + RUN_TEST(api_surface_default_streamlined_regression_gate); + RUN_TEST(api_surface_classic_regression_gate); + RUN_TEST(tool_mode_config_switches_live_server_surface); + RUN_TEST(hidden_tools_reveal_discoverable_tools); + RUN_TEST(codex_client_initial_catalog_exposes_advanced_tools); + RUN_TEST(non_codex_client_initial_catalog_remains_streamlined); + RUN_TEST(hidden_tools_payload_excludes_already_visible_configured_tools); + RUN_TEST(streamlined_reveal_covers_classic_capabilities); + RUN_TEST(query_graph_input_schema_identical_across_modes); + RUN_TEST(streamlined_core_parameter_contract); + RUN_TEST(default_tool_autoindex_description_is_precise); + RUN_TEST(query_graph_description_explains_compositional_value); + RUN_TEST(revealed_trace_path_parameter_contract); + RUN_TEST(revealed_advanced_tool_schema_matches_handlers); + /* Dispatch */ + RUN_TEST(search_graph_dispatch); + RUN_TEST(query_graph_dispatch); + RUN_TEST(get_code_dispatch); + RUN_TEST(canonical_tool_names_dispatch); + /* Path support */ + RUN_TEST(project_param_path_detection); + /* Edge cases */ + RUN_TEST(unknown_tool_returns_error); + RUN_TEST(null_tool_name_returns_error); + /* Progressive disclosure */ + RUN_TEST(streamlined_mode_has_hidden_tools_hint); + RUN_TEST(hidden_tools_still_dispatch); + /* Session context */ + RUN_TEST(search_graph_has_session_project); + RUN_TEST(cli_session_detection_uses_cwd_project_slug); + RUN_TEST(search_graph_slug_project_sets_session_context); + RUN_TEST(search_graph_explicit_project_context_uses_resolved_store_project); + RUN_TEST(index_status_has_session_project); + /* Context injection */ + RUN_TEST(first_response_has_context_header); + RUN_TEST(context_has_schema_info); + /* MCP Resources (Phase 10) */ + RUN_TEST(resources_list_returns_3_resources); + RUN_TEST(resources_read_schema); + RUN_TEST(resources_read_architecture); + RUN_TEST(resources_read_status); + RUN_TEST(resources_read_unknown_uri); + RUN_TEST(initialize_advertises_resources_capability); + RUN_TEST(initialize_parses_client_resources_capability); + RUN_TEST(no_resources_capability_gets_context_injection); + /* MCP spec compliance */ + RUN_TEST(initialize_response_has_protocol_version); + RUN_TEST(initialize_resources_cap_subscribe_false); + RUN_TEST(resources_list_has_mimeType_and_description); + RUN_TEST(resources_read_response_has_contents_array); + RUN_TEST(resources_read_missing_uri_param); + RUN_TEST(resources_read_no_params_at_all); + RUN_TEST(resource_status_and_architecture_report_dirty_metadata); + /* Client behavioral differences */ + RUN_TEST(resource_client_gets_context_only_on_first_call); + RUN_TEST(legacy_client_gets_context_only_on_first_call); + RUN_TEST(empty_resources_capability_still_gets_context); + RUN_TEST(no_initialize_defaults_to_legacy_behavior); + /* MCP resources pull-only: context injection fires for all clients (§17) */ + RUN_TEST(resource_capable_client_gets_context_on_first_call); + RUN_TEST(resource_capable_client_no_context_on_second_call); + /* Tool descriptions reference resources */ + RUN_TEST(tool_descriptions_reference_resources); + RUN_TEST(hidden_tools_hint_mentions_resources); + /* Error message quality */ + RUN_TEST(error_no_project_loaded_has_hint); + RUN_TEST(error_function_not_found_includes_name); + RUN_TEST(error_symbol_not_found_includes_qn); + RUN_TEST(error_missing_required_param_has_hint); + RUN_TEST(error_unknown_tool_lists_valid_tools); + RUN_TEST(error_resource_not_found_has_spec_code); + /* JSON-RPC response structure (e2e) */ + RUN_TEST(resource_error_is_top_level_not_nested_in_result); + RUN_TEST(resource_error_missing_uri_is_top_level); + RUN_TEST(resource_error_no_params_is_top_level); + RUN_TEST(resource_success_has_result_not_error); + RUN_TEST(resource_schema_returns_real_data_when_indexed); + RUN_TEST(resource_status_returns_not_indexed_when_no_store); + /* Dep search bug regressions */ + RUN_TEST(dep_search_explicit_dep_project_name); + RUN_TEST(store_prefix_match_includes_deps); + RUN_TEST(store_prefix_ranks_project_above_dep); + RUN_TEST(store_prefix_ranks_project_above_dep_all_sort_modes); + RUN_TEST(store_prefix_disable_dep_ranking_lets_dep_win); + RUN_TEST(store_exact_match_excludes_deps); + RUN_TEST(is_dep_project_cross_project_detection); + RUN_TEST(e2e_dep_search_returns_project_and_dep_results); + /* Cross-project search prefix collision */ + RUN_TEST(cross_project_search_not_confused_by_prefix); + RUN_TEST(session_dep_search_uses_session_store); + RUN_TEST(exact_session_name_uses_session_store); + RUN_TEST(prefix_collision_dash_after_session_name); + RUN_TEST(prefix_collision_underscore_after_session_name); + RUN_TEST(prefix_collision_longer_name_with_dot_not_dep); + RUN_TEST(prefix_collision_completely_different_project); + RUN_TEST(prefix_collision_session_is_substring_of_project); + /* get_code NULL-project regression */ + RUN_TEST(get_code_no_project_uses_open_store_tier1); + RUN_TEST(get_code_single_fuzzy_result_resolves_not_ambiguous); + RUN_TEST(get_code_cold_start_parses_project_from_qn); + RUN_TEST(watcher_registered_after_index_repository); + RUN_TEST(watcher_registered_on_resolve_store); + RUN_TEST(watcher_not_registered_for_unknown_path); + /* Phase 10.2: Bug fixes and token optimization */ + RUN_TEST(hidden_tools_returns_info_not_error); + RUN_TEST(compact_defaults_to_true); + RUN_TEST(pagerank_output_has_limited_precision); + RUN_TEST(empty_db_not_treated_as_indexed); + /* Exclude param */ + RUN_TEST(search_exclude_filters_file_paths); + RUN_TEST(search_exclude_empty_array_no_effect); + RUN_TEST(search_exclude_all_returns_empty); + RUN_TEST(exclude_param_in_tool_schema); + /* origin/main additions: path_filter param + structured error for missing project */ + RUN_TEST(path_filter_param_in_tool_schema); + RUN_TEST(project_missing_returns_structured_error); + /* Bug fixes: search_code_graph mode/param sharp edges (TDD) */ + RUN_TEST(source_grep_case_insensitive_by_default); + RUN_TEST(source_grep_case_sensitive_flag_works); + RUN_TEST(graph_mode_compact_error_is_descriptive); + RUN_TEST(source_grep_mode_summary_warns); + RUN_TEST(schema_compact_documented_as_graph_only); + RUN_TEST(schema_has_context_param_for_source_grep); +} diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index cc150cc3e..ed806cb2a 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -58,6 +58,19 @@ static int find_resolved(const CBMFileResult *r, const char *callerSub, const ch return -1; } +static int find_resolved_strategy(const CBMFileResult *r, const char *callerSub, + const char *calleeSub, const char *strategy) { + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->confidence > 0 && rc->caller_qn && rc->callee_qn && rc->strategy && + strstr(rc->caller_qn, callerSub) && strstr(rc->callee_qn, calleeSub) && + strcmp(rc->strategy, strategy) == 0) { + return i; + } + } + return -1; +} + static int require_resolved(const CBMFileResult *r, const char *callerSub, const char *calleeSub) { int idx = find_resolved(r, callerSub, calleeSub); if (idx < 0) { @@ -961,6 +974,16 @@ TEST(tslsp_jsx_nested_component) { PASS(); } +TEST(tslsp_jsx_relative_import_waits_for_cross_file_qn) { + CBMFileResult *r = + extract_tsx("import { Widget } from './widget';\n" + "function App(): JSX.Element { return ; }\n"); + ASSERT_NOT_NULL(r); + ASSERT_EQ(find_resolved_strategy(r, "App", "./widget.Widget", "lsp_ts_jsx_import"), -1); + cbm_free_result(r); + PASS(); +} + /* ── Category 23: TSX combined ─────────────────────────────────────────────── */ TEST(tslsp_tsx_typed_props_method_call) { @@ -2454,6 +2477,35 @@ TEST(tslsp_baseline_vs_lsp_simple) { PASS(); } +static bool lsp_disabled_false_value_keeps_ts_lsp_enabled(const char *value) { + const char *source = "class Conn { ping(): void {} }\n" + "function go(c: Conn) { c.ping(); }\n"; + + cbm_setenv("CBM_LSP_DISABLED", "1", 1); + CBMFileResult *base = extract_ts(source); + int br = 0, bu = 0, bt = 0; + count_calls(base, &br, &bu, &bt); + cbm_free_result(base); + + cbm_setenv("CBM_LSP_DISABLED", value, 1); + CBMFileResult *lsp = extract_ts(source); + int lr = 0, lu = 0, lt = 0; + count_calls(lsp, &lr, &lu, <); + cbm_free_result(lsp); + + cbm_unsetenv("CBM_LSP_DISABLED"); + return lr >= br + 1; +} + +TEST(tslsp_disabled_false_values_keep_lsp_enabled) { + ASSERT(lsp_disabled_false_value_keeps_ts_lsp_enabled("0")); + ASSERT(lsp_disabled_false_value_keeps_ts_lsp_enabled("false")); + ASSERT(lsp_disabled_false_value_keeps_ts_lsp_enabled("False")); + ASSERT(lsp_disabled_false_value_keeps_ts_lsp_enabled("off")); + ASSERT(lsp_disabled_false_value_keeps_ts_lsp_enabled("NO")); + PASS(); +} + TEST(tslsp_baseline_vs_lsp_chained) { const char *source = "class Q { where(s: string): Q { return this; } limit(n: number): Q { return this; } " @@ -3975,6 +4027,7 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_jsx_component_with_children); RUN_TEST(tslsp_jsx_intrinsic_skipped); RUN_TEST(tslsp_jsx_nested_component); + RUN_TEST(tslsp_jsx_relative_import_waits_for_cross_file_qn); /* Category 23: TSX combined */ RUN_TEST(tslsp_tsx_typed_props_method_call); @@ -4164,6 +4217,7 @@ SUITE(ts_lsp) { /* LSP vs baseline comparison (requires CBM_LSP_DISABLED knob in resolver) */ RUN_TEST(tslsp_baseline_vs_lsp_simple); + RUN_TEST(tslsp_disabled_false_values_keep_lsp_enabled); RUN_TEST(tslsp_baseline_vs_lsp_chained); RUN_TEST(tslsp_baseline_vs_lsp_callbacks); RUN_TEST(tslsp_baseline_vs_lsp_narrowing); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index cd4144f3e..15dec437e 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -5,12 +5,14 @@ * poll_once behavior. */ #include "../src/foundation/compat.h" +#include "../src/foundation/compat_fs.h" #include "../src/foundation/compat_thread.h" #include "../src/foundation/constants.h" #include "../src/foundation/platform.h" #include "test_framework.h" #include "test_helpers.h" #include +#include #include #include #include @@ -45,42 +47,64 @@ static const char *wt_path(char *buf, size_t n, const char *dir, const char *rel return buf; } +static bool wt_path_list_contains(char **paths, int count, const char *needle) { + for (int i = 0; i < count; i++) { + if (paths[i] && strcmp(paths[i], needle) == 0) { + return true; + } + } + return false; +} + +static bool wt_mktempdir(char *buf, size_t n, const char *prefix) { + char *path = th_mktempdir(prefix); + if (!path) { + return false; + } + int written = snprintf(buf, n, "%s", path); + if (written < 0 || (size_t)written >= n) { + th_rmtree(path); + return false; + } + return true; +} + /* ══════════════════════════════════════════════════════════════════ * ADAPTIVE INTERVAL * ══════════════════════════════════════════════════════════════════ */ TEST(poll_interval_base) { /* 0 files → 5s base */ - int ms = cbm_watcher_poll_interval_ms(0); + int ms = cbm_watcher_poll_interval_ms(0, 0, 0); ASSERT_EQ(ms, 5000); PASS(); } TEST(poll_interval_scaling) { /* 1000 files → 5000 + 2*1000 = 7000ms */ - int ms = cbm_watcher_poll_interval_ms(1000); + int ms = cbm_watcher_poll_interval_ms(1000, 0, 0); ASSERT_EQ(ms, 7000); /* 5000 files → 5000 + 10*1000 = 15000ms */ - ms = cbm_watcher_poll_interval_ms(5000); + ms = cbm_watcher_poll_interval_ms(5000, 0, 0); ASSERT_EQ(ms, 15000); PASS(); } TEST(poll_interval_cap) { /* 100K files → capped at 60s */ - int ms = cbm_watcher_poll_interval_ms(100000); + int ms = cbm_watcher_poll_interval_ms(100000, 0, 0); ASSERT_EQ(ms, 60000); PASS(); } TEST(poll_interval_small) { /* 499 files → 5000 + 0*1000 = 5000ms (integer division) */ - int ms = cbm_watcher_poll_interval_ms(499); + int ms = cbm_watcher_poll_interval_ms(499, 0, 0); ASSERT_EQ(ms, 5000); /* 500 files → 5000 + 1*1000 = 6000ms */ - ms = cbm_watcher_poll_interval_ms(500); + ms = cbm_watcher_poll_interval_ms(500, 0, 0); ASSERT_EQ(ms, 6000); PASS(); } @@ -174,6 +198,130 @@ TEST(watcher_null_safety) { PASS(); } +/* argv execution has no fixed shell-command formatting buffer. Registration + * should not reject a nonempty path merely because its textual length exceeds + * the old 1 KiB command string; filesystem existence is handled by polling. */ +TEST(watcher_has_no_shell_command_buffer_path_limit) { + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); + + char long_path[CBM_SZ_2K]; + long_path[0] = '/'; + memset(long_path + 1, 'a', sizeof(long_path) - CBM_SZ_2); + long_path[sizeof(long_path) - 1] = '\0'; + + ASSERT_TRUE(cbm_watcher_watch(w, "long-argv-path", long_path)); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + + cbm_watcher_free(w); + cbm_store_close(store); + PASS(); +} + +TEST(git_snapshot_has_no_shell_command_buffer_path_limit) { + char long_path[CBM_SZ_2K]; + long_path[0] = '/'; + memset(long_path + 1, 'b', sizeof(long_path) - CBM_SZ_2); + long_path[sizeof(long_path) - 1] = '\0'; + + cbm_git_snapshot_t snap = {0}; + ASSERT_TRUE(cbm_git_snapshot_path_supported(long_path)); + ASSERT_EQ(cbm_git_snapshot_read(long_path, CBM_GIT_SNAPSHOT_HEAD, &snap), 0); + ASSERT_TRUE(snap.path_supported); + ASSERT_FALSE(snap.is_git); + PASS(); +} + +TEST(git_snapshot_non_git_path) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_git_snapshot_nongit_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + + cbm_git_snapshot_t snap = {0}; + ASSERT_EQ(cbm_git_snapshot_read(tmpdir, + CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_DIRTY | + CBM_GIT_SNAPSHOT_FILE_COUNT, + &snap), + 0); + ASSERT_TRUE(snap.path_supported); + ASSERT_FALSE(snap.is_git); + ASSERT_EQ(snap.file_count, 0); + ASSERT_STR_EQ(snap.dirty_hash, "0000000000000000"); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(git_snapshot_clean_and_dirty_repo) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_git_snapshot_repo_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_git_snapshot_t snap = {0}; + ASSERT_EQ(cbm_git_snapshot_read(tmpdir, + CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_DIRTY | + CBM_GIT_SNAPSHOT_FILE_COUNT, + &snap), + 0); + ASSERT_TRUE(snap.path_supported); + ASSERT_TRUE(snap.is_git); + ASSERT_TRUE(snap.head[0] != '\0'); + ASSERT_EQ(snap.file_count, 1); + ASSERT_EQ(snap.dirty_bytes, 0); + ASSERT_STR_EQ(snap.dirty_hash, "0000000000000000"); + + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "new.go"), "package main\n"); + } + ASSERT_EQ(cbm_git_snapshot_read(tmpdir, CBM_GIT_SNAPSHOT_DIRTY, &snap), 0); + ASSERT_TRUE(snap.is_git); + ASSERT_TRUE(snap.dirty_bytes > 0); + ASSERT_TRUE(strcmp(snap.dirty_hash, "0000000000000000") != 0); + + th_rmtree(tmpdir); + PASS(); +} + +TEST(git_status_paths_tracks_rename_current_and_previous_path) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_gitpaths_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } + { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "old.go"), + "package main\n\nfunc Old() {}\n"); } + wt_git(tmpdir, "add old.go"); + wt_git(tmpdir, "commit -q -m init"); + + ASSERT_EQ(wt_git(tmpdir, "mv old.go new.go"), 0); + char **paths = NULL; + int count = 0; + ASSERT_EQ(cbm_git_status_paths(tmpdir, &paths, &count), 0); + ASSERT_TRUE(wt_path_list_contains(paths, count, "new.go")); + ASSERT_TRUE(wt_path_list_contains(paths, count, "old.go")); + cbm_git_status_paths_free(paths, count); + + th_rmtree(tmpdir); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * POLL WITH REAL GIT REPO * ══════════════════════════════════════════════════════════════════ */ @@ -188,6 +336,36 @@ static int index_callback(const char *name, const char *path, void *ud) { return 0; } +static int always_failing_index_callback(const char *name, const char *path, void *ud) { + (void)name; + (void)path; + (void)ud; + index_call_count++; + return CBM_NOT_FOUND; +} + +typedef struct { + cbm_watcher_t *watcher; + const char *replacement_path; + int calls; +} watcher_mutating_cb_t; + +static int unwatching_index_callback(const char *name, const char *path, void *ud) { + (void)path; + watcher_mutating_cb_t *ctx = (watcher_mutating_cb_t *)ud; + ctx->calls++; + cbm_watcher_unwatch(ctx->watcher, name); + return 0; +} + +static int replacing_index_callback(const char *name, const char *path, void *ud) { + (void)path; + watcher_mutating_cb_t *ctx = (watcher_mutating_cb_t *)ud; + ctx->calls++; + cbm_watcher_watch(ctx->watcher, name, ctx->replacement_path); + return 0; +} + TEST(watcher_poll_no_projects) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); @@ -200,6 +378,78 @@ TEST(watcher_poll_no_projects) { PASS(); } +TEST(watcher_unwatch_during_poll_callback_no_uaf) { + char tmpdir[CBM_SZ_256]; + if (!wt_mktempdir(tmpdir, sizeof(tmpdir), "cbm_watcher_unwatch_cb")) { + FAIL("cbm_mkdtemp failed"); + } + if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } + { char p[CBM_SZ_512]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m init"); + + watcher_mutating_cb_t cb = {0}; + cbm_watcher_t *w = cbm_watcher_new(NULL, unwatching_index_callback, &cb); + cb.watcher = w; + cbm_watcher_watch(w, "unwatch-cb", tmpdir); + cbm_watcher_poll_once(w); /* baseline */ + + { char p[CBM_SZ_512]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "changed\n"); } + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m changed"); + + cbm_watcher_touch(w, "unwatch-cb"); + ASSERT_EQ(cbm_watcher_poll_once(w), 1); + ASSERT_EQ(cb.calls, 1); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + ASSERT_EQ(cbm_watcher_poll_once(w), 0); + + cbm_watcher_free(w); + th_rmtree(tmpdir); + PASS(); +} + +TEST(watcher_replace_during_poll_callback_no_uaf) { + char tmpdir_a[CBM_SZ_256]; + char tmpdir_b[CBM_SZ_256]; + bool made_a = wt_mktempdir(tmpdir_a, sizeof(tmpdir_a), "cbm_watcher_replace_a"); + bool made_b = wt_mktempdir(tmpdir_b, sizeof(tmpdir_b), "cbm_watcher_replace_b"); + if (!made_a || !made_b) { + if (made_a) th_rmtree(tmpdir_a); + if (made_b) th_rmtree(tmpdir_b); + FAIL("cbm_mkdtemp failed"); + } + if (wt_git(tmpdir_a, "init -q") != 0) { + th_rmtree(tmpdir_a); + th_rmtree(tmpdir_b); + FAIL("git init failed"); + } + { char p[CBM_SZ_512]; th_write_file(wt_path(p, sizeof(p), tmpdir_a, "file.txt"), "hello\n"); } + wt_git(tmpdir_a, "add file.txt"); + wt_git(tmpdir_a, "commit -q -m init"); + + watcher_mutating_cb_t cb = {.replacement_path = tmpdir_b}; + cbm_watcher_t *w = cbm_watcher_new(NULL, replacing_index_callback, &cb); + cb.watcher = w; + cbm_watcher_watch(w, "replace-cb", tmpdir_a); + cbm_watcher_poll_once(w); /* baseline */ + + { char p[CBM_SZ_512]; th_append_file(wt_path(p, sizeof(p), tmpdir_a, "file.txt"), "changed\n"); } + wt_git(tmpdir_a, "add file.txt"); + wt_git(tmpdir_a, "commit -q -m changed"); + + cbm_watcher_touch(w, "replace-cb"); + ASSERT_EQ(cbm_watcher_poll_once(w), 1); + ASSERT_EQ(cb.calls, 1); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + ASSERT_EQ(cbm_watcher_poll_once(w), 0); /* replacement path gets its own baseline */ + + cbm_watcher_free(w); + th_rmtree(tmpdir_a); + th_rmtree(tmpdir_b); + PASS(); +} + TEST(watcher_poll_nonexistent_path) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); @@ -661,7 +911,7 @@ TEST(watcher_stop_flag) { cbm_watcher_stop(w); /* Run should return immediately */ - int rc = cbm_watcher_run(w, 1000); + int rc = cbm_watcher_run(w, 1000, 0); ASSERT_EQ(rc, 0); cbm_watcher_free(w); @@ -669,6 +919,59 @@ TEST(watcher_stop_flag) { PASS(); } +typedef struct { + cbm_watcher_t *watcher; + int run_status; +} watcher_run_context_t; + +static void *watcher_run_for_stop_test(void *opaque) { + watcher_run_context_t *context = opaque; + enum { WATCHER_TEST_LONG_POLL_MS = 5000 }; + context->run_status = + cbm_watcher_run(context->watcher, WATCHER_TEST_LONG_POLL_MS, WATCHER_TEST_LONG_POLL_MS); + return NULL; +} + +TEST(watcher_stop_wakes_parked_run_loop) { + enum { + WATCHER_TEST_WAIT_READY_MS = 5000, + /* A synchronized condition wake is normally sub-millisecond. This is + * deliberately a coarse hang detector: it remains tolerant of loaded + * sanitizer/Windows runners while rejecting the former 500 ms polling + * sleep that delayed every retiring daemon. */ + WATCHER_TEST_STOP_WAKE_MAX_MS = 400, + }; + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); + watcher_run_context_t context = { + .watcher = watcher, + .run_status = CBM_NOT_FOUND, + }; + cbm_thread_t thread = {0}; + int create_status = watcher ? cbm_thread_create(&thread, 0, watcher_run_for_stop_test, &context) + : CBM_NOT_FOUND; + uint64_t ready_deadline = cbm_now_ms() + WATCHER_TEST_WAIT_READY_MS; + while (create_status == 0 && cbm_watcher_waiter_count_for_test(watcher) == 0 && + cbm_now_ms() < ready_deadline) { + cbm_usleep(CBM_USEC_PER_MSEC); + } + bool parked = create_status == 0 && cbm_watcher_waiter_count_for_test(watcher) == 1; + uint64_t stop_started_ms = cbm_now_ms(); + if (watcher) { + cbm_watcher_stop(watcher); + } + int join_status = create_status == 0 ? cbm_thread_join(&thread) : CBM_NOT_FOUND; + uint64_t stop_elapsed_ms = cbm_now_ms() - stop_started_ms; + + ASSERT_TRUE(parked); + ASSERT_EQ(join_status, 0); + ASSERT_EQ(context.run_status, 0); + ASSERT_TRUE(stop_elapsed_ms <= WATCHER_TEST_STOP_WAKE_MAX_MS); + cbm_watcher_free(watcher); + cbm_store_close(store); + PASS(); +} + /* test_main turns an exact private-path alias named git/git.exe into a * deterministic child that publishes its PID and ignores graceful shutdown. * This reproduces the production failure where popen() left watcher shutdown @@ -695,7 +998,9 @@ typedef struct { static void *watcher_windows_blocked_run_thread(void *opaque) { watcher_windows_blocked_run_t *run = opaque; - (void)cbm_watcher_run(run->watcher, 10); + /* base_ms == max_ms pins the cadence at upstream's fixed 10 ms; passing 0 + * for max_ms would restore the 60 s adaptive cap this test must not wait on. */ + (void)cbm_watcher_run(run->watcher, 10, 10); atomic_store_explicit(&run->completed, true, memory_order_release); return NULL; } @@ -858,7 +1163,9 @@ typedef struct { static void *watcher_blocked_run_thread(void *opaque) { watcher_blocked_run_t *run = opaque; - (void)cbm_watcher_run(run->watcher, 10); + /* base_ms == max_ms pins the cadence at upstream's fixed 10 ms; passing 0 + * for max_ms would restore the 60 s adaptive cap this test must not wait on. */ + (void)cbm_watcher_run(run->watcher, 10, 10); atomic_store_explicit(&run->completed, true, memory_order_release); return NULL; } @@ -1180,16 +1487,13 @@ TEST(watcher_detects_git_commit) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { + char file_path[512]; + if (wt_git(tmpdir, "init -q") != 0 || + th_write_file(wt_path(file_path, sizeof(file_path), tmpdir, "file.txt"), "hello\n") != 0 || + wt_git(tmpdir, "add file.txt") != 0 || wt_git(tmpdir, "commit -q -m init") != 0) { th_rmtree(tmpdir); - FAIL("git init failed"); - } - { - char p[300]; - th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + FAIL("git fixture setup failed"); } - wt_git(tmpdir, "add file.txt"); - wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); @@ -1202,12 +1506,13 @@ TEST(watcher_detects_git_commit) { ASSERT_EQ(index_call_count, 0); /* Make a change: new commit */ - { - char p[300]; - th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "world\n"); + if (th_append_file(file_path, "world\n") != 0 || wt_git(tmpdir, "add file.txt") != 0 || + wt_git(tmpdir, "commit -q -m add-world") != 0) { + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + FAIL("git fixture mutation failed"); } - wt_git(tmpdir, "add file.txt"); - wt_git(tmpdir, "commit -q -m add-world"); /* Touch to bypass interval, then poll */ cbm_watcher_touch(w, "temp-repo"); @@ -1311,6 +1616,134 @@ TEST(watcher_detects_dirty_worktree) { PASS(); } +TEST(watcher_marks_dirty_file_before_failed_index_callback) { + char tmpdir[256]; + char dirty_path[300]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_ledger_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } + { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.go"), + "package main\n\nfunc Old() {}\n"); } + wt_git(tmpdir, "add file.go"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "dirty-ledger-repo", tmpdir), CBM_STORE_OK); + cbm_watcher_t *w = cbm_watcher_new(store, always_failing_index_callback, NULL); + ASSERT_NOT_NULL(w); + + cbm_watcher_watch(w, "dirty-ledger-repo", tmpdir); + index_call_count = 0; + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + th_append_file(wt_path(dirty_path, sizeof(dirty_path), tmpdir, "file.go"), + "\nfunc NewDirty() {}\n"); + + cbm_watcher_touch(w, "dirty-ledger-repo"); + ASSERT_EQ(cbm_watcher_poll_once(w), 0); + ASSERT_EQ(index_call_count, 1); + + int pending = -1; + int overlay_ready = -1; + ASSERT_EQ(cbm_store_count_dirty_files(store, "dirty-ledger-repo", &pending, + &overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(pending, 1); + ASSERT_EQ(overlay_ready, 0); + cbm_dirty_file_state_t *dirty_rows = NULL; + int dirty_row_count = 0; + ASSERT_EQ(cbm_store_list_dirty_files(store, "dirty-ledger-repo", &dirty_rows, + &dirty_row_count), + CBM_STORE_OK); + ASSERT_EQ(dirty_row_count, 1); + ASSERT_STR_EQ(dirty_rows[0].rel_path, "file.go"); + char expected_hash[CBM_FILE_CONTENT_HASH_BUFSZ] = ""; + ASSERT_EQ(cbm_file_content_hash(dirty_path, expected_hash, sizeof(expected_hash)), 0); + ASSERT_STR_EQ(dirty_rows[0].observed_hash, expected_hash); + struct stat dirty_stat; + ASSERT_EQ(stat(dirty_path, &dirty_stat), 0); + ASSERT_EQ(dirty_rows[0].observed_mtime_ns, cbm_stat_mtime_ns(&dirty_stat)); + ASSERT_EQ(dirty_rows[0].observed_size, dirty_stat.st_size); + cbm_store_free_dirty_files(dirty_rows, dirty_row_count); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + +TEST(watcher_dirty_ledger_records_paths_after_former_4096_limit) { + enum { DIRTY_FILE_COUNT = CBM_SZ_4K + SKIP_ONE }; + char tmpdir[256]; + char abs_path[384]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_ledger_many_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + th_write_file(wt_path(abs_path, sizeof(abs_path), tmpdir, "seed.txt"), "seed\n"); + wt_git(tmpdir, "add seed.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, "dirty-ledger-many", tmpdir), CBM_STORE_OK); + cbm_watcher_t *w = cbm_watcher_new(store, always_failing_index_callback, NULL); + ASSERT_NOT_NULL(w); + + cbm_watcher_watch(w, "dirty-ledger-many", tmpdir); + index_call_count = 0; + ASSERT_EQ(cbm_watcher_poll_once(w), 0); + ASSERT_EQ(index_call_count, 0); + + char rel_path[64]; + for (int i = 0; i < DIRTY_FILE_COUNT; i++) { + snprintf(rel_path, sizeof(rel_path), "dirty-%04d.txt", i); + th_write_file(wt_path(abs_path, sizeof(abs_path), tmpdir, rel_path), "x\n"); + } + + cbm_watcher_touch(w, "dirty-ledger-many"); + ASSERT_EQ(cbm_watcher_poll_once(w), 0); + ASSERT_EQ(index_call_count, 1); + + int pending = -1; + int overlay_ready = -1; + ASSERT_EQ(cbm_store_count_dirty_files(store, "dirty-ledger-many", &pending, + &overlay_ready), + CBM_STORE_OK); + ASSERT_EQ(pending, DIRTY_FILE_COUNT); + ASSERT_EQ(overlay_ready, 0); + + cbm_dirty_file_state_t *dirty_rows = NULL; + int dirty_row_count = 0; + ASSERT_EQ(cbm_store_list_dirty_files(store, "dirty-ledger-many", &dirty_rows, + &dirty_row_count), + CBM_STORE_OK); + ASSERT_EQ(dirty_row_count, DIRTY_FILE_COUNT); + bool found_last = false; + for (int i = 0; i < dirty_row_count; i++) { + if (dirty_rows[i].rel_path && + strcmp(dirty_rows[i].rel_path, "dirty-4096.txt") == 0) { + found_last = true; + break; + } + } + ASSERT_TRUE(found_last); + cbm_store_free_dirty_files(dirty_rows, dirty_row_count); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + TEST(watcher_identical_watch_preserves_dirty_baseline) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_same_root_XXXXXX"); @@ -1796,7 +2229,7 @@ TEST(watcher_poll_interval_full_table) { }; int n = (int)(sizeof(tests) / sizeof(tests[0])); for (int i = 0; i < n; i++) { - int got = cbm_watcher_poll_interval_ms(tests[i].files); + int got = cbm_watcher_poll_interval_ms(tests[i].files, 0, 0); if (got != tests[i].expected_ms) { fprintf(stderr, "FAIL pollInterval(%d) = %d, want %d\n", tests[i].files, got, tests[i].expected_ms); @@ -1942,8 +2375,8 @@ TEST(watcher_continued_dirty) { } TEST(watcher_baseline_dirty_repo) { - /* Baseline on a repo that already has uncommitted changes. - * Port of TestGitSentinelDetectsEdit (dirty from the start). */ + /* #937: a dirty tree present before baseline is indexed once because a + * restored artifact may not contain that state. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) @@ -1957,7 +2390,11 @@ TEST(watcher_baseline_dirty_repo) { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } - wt_git(tmpdir, "add file.txt"); + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); + } + wt_git(tmpdir, "add file.txt file2.txt"); wt_git(tmpdir, "commit -q -m init"); /* Make dirty BEFORE baseline */ @@ -1972,15 +2409,25 @@ TEST(watcher_baseline_dirty_repo) { cbm_watcher_watch(w, "bld-repo", tmpdir); index_call_count = 0; - /* Baseline — captures HEAD but doesn't check for dirty */ + /* Baseline captures HEAD but deliberately leaves dirty state pending. */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ - /* First real poll — should detect the pre-existing dirty state */ + /* First real poll indexes the pre-existing dirty state once. */ cbm_watcher_touch(w, "bld-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); + /* A distinct dirty state is indexed again. */ + { + char _p[1024]; + snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir); + th_append_file(_p, "dirty after baseline\n"); + } + cbm_watcher_touch(w, "bld-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); + cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); @@ -2051,7 +2498,11 @@ TEST(watcher_watch_after_unwatch) { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } - wt_git(tmpdir, "add file.txt"); + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); + } + wt_git(tmpdir, "add file.txt file2.txt"); wt_git(tmpdir, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); @@ -2079,11 +2530,28 @@ TEST(watcher_watch_after_unwatch) { cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ - /* Second poll — detects dirty */ + /* The dirty state appeared while unwatched, so the fresh baseline cannot + * prove it is present in the DB. Index it once (#937); callers that just + * completed an explicit index use cbm_watcher_mark_indexed() instead. */ cbm_watcher_touch(w, "rewatch-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); + /* The same dirty signature must then stay quiet (no write amplification). */ + cbm_watcher_touch(w, "rewatch-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* A later dirty-status change still triggers. */ + { + char _p[1024]; + snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir); + th_append_file(_p, "dirty after rewatch baseline\n"); + } + cbm_watcher_touch(w, "rewatch-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); + cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); @@ -2508,6 +2976,287 @@ TEST(watcher_modify_tracked_file) { PASS(); } +TEST(watcher_dirty_hash_stable) { + /* Core fix: after first reindex for a dirty tree, repeated polls with the + * same dirty content must NOT retrigger (same porcelain hash). */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dhs_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char file_path[512]; + if (wt_git(tmpdir, "init -q") != 0 || + th_write_file(wt_path(file_path, sizeof(file_path), tmpdir, "file.txt"), "hello\n") != 0 || + wt_git(tmpdir, "add file.txt") != 0 || wt_git(tmpdir, "commit -q -m init") != 0) { + th_rmtree(tmpdir); + FAIL("git fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "dhs-repo", tmpdir); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make dirty */ + ASSERT_EQ(th_append_file(file_path, "dirty\n"), 0); + + /* First poll after edit → reindex */ + cbm_watcher_touch(w, "dhs-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* Three more polls with identical dirty content → no further reindexes */ + cbm_watcher_touch(w, "dhs-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_touch(w, "dhs-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_touch(w, "dhs-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* stable — hash-based dedup works */ + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + +TEST(watcher_dirty_content_change_retriggered) { + /* Fix 1 bidirectionality: after first dirty reindex, changing dirty content + * (different porcelain output → different hash) MUST fire a second reindex. + * This proves hash-based detection allows new changes, not just blocks all. */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dcc_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char file_path[512]; + char file2_path[512]; + /* Commit two files so dirtying each produces a distinct porcelain output. */ + if (wt_git(tmpdir, "init -q") != 0 || + th_write_file(wt_path(file_path, sizeof(file_path), tmpdir, "file.txt"), "hello\n") != 0 || + th_write_file(wt_path(file2_path, sizeof(file2_path), tmpdir, "file2.txt"), "world\n") != 0 || + wt_git(tmpdir, "add file.txt file2.txt") != 0 || + wt_git(tmpdir, "commit -q -m init") != 0) { + th_rmtree(tmpdir); + FAIL("git fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "dcc-repo", tmpdir); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* First edit — dirty file.txt only; porcelain = " M file.txt" */ + ASSERT_EQ(th_append_file(file_path, "edit-A\n"), 0); + cbm_watcher_touch(w, "dcc-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* first dirty detection */ + + /* Same dirty state — no retrigger */ + cbm_watcher_touch(w, "dcc-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* hash stable → no reindex */ + + /* Second edit — also dirty file2.txt; porcelain now has two modified files + * → different hash → must trigger a second reindex */ + ASSERT_EQ(th_append_file(file2_path, "edit-B\n"), 0); + cbm_watcher_touch(w, "dcc-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); /* new dirty hash → second reindex */ + + /* Same dirty state again — stable */ + cbm_watcher_touch(w, "dcc-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); /* stable */ + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + +TEST(watcher_watch_path_change_resets_state) { + /* Fix 2 correctness: re-watching same project with a DIFFERENT path must + * replace state (new baseline), not return early. This verifies the path + * comparison in cbm_watcher_watch() is working correctly. */ + char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_pca_XXXXXX"); + char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_pcb_XXXXXX"); + if (!cbm_mkdtemp(tmpdirA)) { + FAIL("cbm_mkdtemp failed for fixture A"); + } + if (!cbm_mkdtemp(tmpdirB)) { + th_rmtree(tmpdirA); + FAIL("cbm_mkdtemp failed for fixture B"); + } + + char path_a[512]; + char path_b[512]; + /* Init repo A. */ + if (wt_git(tmpdirA, "init -q") != 0 || + th_write_file(wt_path(path_a, sizeof(path_a), tmpdirA, "a.txt"), "repoA\n") != 0 || + wt_git(tmpdirA, "add a.txt") != 0 || wt_git(tmpdirA, "commit -q -m init-A") != 0) { + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + FAIL("git fixture A setup failed"); + } + /* Init repo B (already clean — nothing to detect after baseline) */ + if (wt_git(tmpdirB, "init -q") != 0 || + th_write_file(wt_path(path_b, sizeof(path_b), tmpdirB, "b.txt"), "repoB\n") != 0 || + wt_git(tmpdirB, "add b.txt") != 0 || wt_git(tmpdirB, "commit -q -m init-B") != 0) { + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + FAIL("git fixture B setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + + /* Watch project-X pointing at A */ + cbm_watcher_watch(w, "project-X", tmpdirA); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + index_call_count = 0; + + /* Baseline on A */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make A dirty and trigger reindex so state has accumulated head+hash */ + ASSERT_EQ(th_append_file(path_a, "dirty-A\n"), 0); + cbm_watcher_touch(w, "project-X"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* Re-watch project-X with path B — must replace state, not return early */ + cbm_watcher_watch(w, "project-X", tmpdirB); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* still 1 project */ + + /* First poll on B → baseline (no reindex) */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* baseline never triggers */ + + /* Second poll on B (clean) → no reindex */ + cbm_watcher_touch(w, "project-X"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* B is clean */ + + /* Now dirty B → detect */ + ASSERT_EQ(th_append_file(path_b, "dirty-B\n"), 0); + cbm_watcher_touch(w, "project-X"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); /* B's dirty state detected */ + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + PASS(); +} + +TEST(watcher_watch_idempotent) { + /* Fix 2: calling cbm_watcher_watch() twice with same project+path must be + * idempotent — state is preserved (no reset of baseline or dirty hash). */ + char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_wid_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char file_path[512]; + if (wt_git(tmpdir, "init -q") != 0 || + th_write_file(wt_path(file_path, sizeof(file_path), tmpdir, "file.txt"), "hello\n") != 0 || + wt_git(tmpdir, "add file.txt") != 0 || wt_git(tmpdir, "commit -q -m init") != 0) { + th_rmtree(tmpdir); + FAIL("git fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + + /* First watch */ + cbm_watcher_watch(w, "wid-repo", tmpdir); + index_call_count = 0; + + /* Baseline poll */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make dirty and trigger reindex */ + ASSERT_EQ(th_append_file(file_path, "dirty\n"), 0); + cbm_watcher_touch(w, "wid-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* Re-watch same project+path (idempotent — must not reset state) */ + cbm_watcher_watch(w, "wid-repo", tmpdir); + + /* Poll with same dirty content — if state was reset, baseline re-runs then + * dirty detection fires again (count would become 2). With the fix it stays 1. */ + cbm_watcher_touch(w, "wid-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* idempotent watch: state preserved */ + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + +TEST(watcher_mark_indexed_refreshes_existing_baseline) { + /* Explicit index_repository observes the current worktree. The watcher must + * not reindex that same dirty status after the response, but later status + * changes still need to trigger. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mark_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } + { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); } + wt_git(tmpdir, "add file.txt file2.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "mark-repo", tmpdir); + index_call_count = 0; + + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + { + char _p[1024]; + snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); + th_append_file(_p, "dirty indexed explicitly\n"); + } + cbm_watcher_mark_indexed(w, "mark-repo", tmpdir); + cbm_watcher_touch(w, "mark-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + { + char _p[1024]; + snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir); + th_append_file(_p, "dirty after explicit index\n"); + } + cbm_watcher_touch(w, "mark-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * RESOURCE MANAGEMENT & AUTO-INDEXING BEHAVIOR * ══════════════════════════════════════════════════════════════════ */ @@ -2614,14 +3363,14 @@ TEST(watcher_touch_nonexistent_project) { TEST(watcher_poll_interval_zero_files) { /* 0 files → base interval (5000ms) */ - int ms = cbm_watcher_poll_interval_ms(0); + int ms = cbm_watcher_poll_interval_ms(0, 0, 0); ASSERT_EQ(ms, 5000); PASS(); } TEST(watcher_poll_interval_small_files) { /* 100 files → should be close to base (5000ms) */ - int ms = cbm_watcher_poll_interval_ms(100); + int ms = cbm_watcher_poll_interval_ms(100, 0, 0); ASSERT_GTE(ms, 5000); /* 100 files / 500 = 0 extra seconds of scaling → 5000ms */ ASSERT_EQ(ms, 5000); @@ -2630,24 +3379,24 @@ TEST(watcher_poll_interval_small_files) { TEST(watcher_poll_interval_medium_files) { /* 10000 files → 5000 + 20*1000 = 25000ms */ - int ms = cbm_watcher_poll_interval_ms(10000); + int ms = cbm_watcher_poll_interval_ms(10000, 0, 0); ASSERT_EQ(ms, 25000); PASS(); } TEST(watcher_poll_interval_capped) { /* 100000 files → capped at 60000ms */ - int ms = cbm_watcher_poll_interval_ms(100000); + int ms = cbm_watcher_poll_interval_ms(100000, 0, 0); ASSERT_EQ(ms, 60000); /* Even larger → still capped */ - ms = cbm_watcher_poll_interval_ms(500000); + ms = cbm_watcher_poll_interval_ms(500000, 0, 0); ASSERT_EQ(ms, 60000); PASS(); } TEST(watcher_poll_interval_negative) { /* Negative file count → should handle gracefully (no crash) */ - int ms = cbm_watcher_poll_interval_ms(-1); + int ms = cbm_watcher_poll_interval_ms(-1, 0, 0); /* Result should be at least the base interval or 0 — just no crash */ ASSERT_GTE(ms, 0); PASS(); @@ -2715,7 +3464,7 @@ TEST(watcher_stop_prevents_run) { cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_stop(w); - int rc = cbm_watcher_run(w, 60000); + int rc = cbm_watcher_run(w, 60000, 0); ASSERT_EQ(rc, 0); cbm_watcher_free(w); @@ -3023,6 +3772,11 @@ SUITE(watcher) { RUN_TEST(watcher_watch_replace); RUN_TEST(watcher_stopped_rejects_new_registration); RUN_TEST(watcher_null_safety); + RUN_TEST(watcher_has_no_shell_command_buffer_path_limit); + RUN_TEST(git_snapshot_has_no_shell_command_buffer_path_limit); + RUN_TEST(git_snapshot_non_git_path); + RUN_TEST(git_snapshot_clean_and_dirty_repo); + RUN_TEST(git_status_paths_tracks_rename_current_and_previous_path); /* Polling */ RUN_TEST(watcher_poll_no_projects); @@ -3037,12 +3791,15 @@ SUITE(watcher) { RUN_TEST(watcher_root_restore_resets_prune_streak); RUN_TEST(watcher_poll_this_repo); RUN_TEST(watcher_stop_flag); + RUN_TEST(watcher_stop_wakes_parked_run_loop); RUN_TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop); /* Git change detection */ RUN_TEST(watcher_detects_git_commit); RUN_TEST(watcher_detects_sha256_git_commit); RUN_TEST(watcher_detects_dirty_worktree); + RUN_TEST(watcher_marks_dirty_file_before_failed_index_callback); + RUN_TEST(watcher_dirty_ledger_records_paths_after_former_4096_limit); RUN_TEST(watcher_identical_watch_preserves_dirty_baseline); RUN_TEST(watcher_detects_new_file); RUN_TEST(watcher_no_change_no_reindex); @@ -3061,8 +3818,15 @@ SUITE(watcher) { RUN_TEST(watcher_git_removed_no_crash); RUN_TEST(watcher_continued_dirty); RUN_TEST(watcher_baseline_dirty_repo); + RUN_TEST(watcher_dirty_hash_stable); + RUN_TEST(watcher_dirty_content_change_retriggered); + RUN_TEST(watcher_watch_path_change_resets_state); + RUN_TEST(watcher_watch_idempotent); + RUN_TEST(watcher_mark_indexed_refreshes_existing_baseline); RUN_TEST(watcher_unwatch_prunes_state); RUN_TEST(watcher_watch_after_unwatch); + RUN_TEST(watcher_unwatch_during_poll_callback_no_uaf); + RUN_TEST(watcher_replace_during_poll_callback_no_uaf); /* FSNotify ports (adapted for git-based detection) */ RUN_TEST(watcher_detects_file_delete); diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index e3382206d..9b7961765 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -409,26 +409,36 @@ require( ) # On Windows subprocess supervision receives a non-NULL lpApplicationName, so a -# literal `git` would not use PATH. Resolve only git.exe beneath inherited -# absolute PATH entries and never permit the current-directory search implied by -# empty or relative entries. POSIX retains execvp via argv[0]. +# literal `git` would not use PATH. The shared Git runner owns the resolver after +# 4c371b0a removed the watcher's duplicate implementation. Verify both halves of +# that boundary: the watcher must delegate to the shared resolver, and the +# resolver must accept only git.exe beneath inherited absolute PATH entries, +# never the current-directory search implied by empty or relative entries. +# POSIX retains execvp via argv[0]. watcher_source = read("src/watcher/watcher.c") +git_command_source = read("src/git/git_command.c") require( all( needle in watcher_source for needle in ( - "watcher_resolve_git_executable", + "cbm_git_resolve_executable", + ".bin = git_executable", + ".bin = argv[0]", + ) + ) + and all( + needle in git_command_source + for needle in ( 'GetEnvironmentVariableW(L"PATH"', 'L"%ls\\\\git.exe"', "GetFullPathNameW", - "watcher_windows_path_absolute", + "git_windows_path_absolute", "FILE_FLAG_OPEN_REPARSE_POINT", - ".bin = git_executable", - ".bin = argv[0]", - "empty/relative entries", + "entry_length == 0U", ) ) - and "popen(" not in watcher_source, + and "popen(" not in watcher_source + and "popen(" not in git_command_source, "Windows watcher Git commands must resolve an explicit absolute git.exe without cwd " "search while POSIX retains literal argv supervision", ) diff --git a/tests/test_worker_pool.c b/tests/test_worker_pool.c index 612a159e3..25720f18e 100644 --- a/tests/test_worker_pool.c +++ b/tests/test_worker_pool.c @@ -13,6 +13,34 @@ /* ── System Info Tests ────────────────────────────────────────────── */ +typedef struct { + cbm_system_info_t *infos; +} system_info_parallel_ctx_t; + +static void system_info_parallel_worker(int idx, void *ctx_ptr) { + system_info_parallel_ctx_t *ctx = ctx_ptr; + ctx->infos[idx] = cbm_system_info(); +} + +TEST(system_info_parallel_first_call_consistent) { + enum { SYSINFO_PARALLEL_CALLS = 64, SYSINFO_PARALLEL_WORKERS = 4 }; + cbm_system_info_t infos[SYSINFO_PARALLEL_CALLS]; + memset(infos, 0, sizeof(infos)); + system_info_parallel_ctx_t ctx = {.infos = infos}; + + cbm_parallel_for_opts_t opts = {.max_workers = SYSINFO_PARALLEL_WORKERS, + .force_pthreads = false}; + cbm_parallel_for(SYSINFO_PARALLEL_CALLS, system_info_parallel_worker, &ctx, opts); + + ASSERT_GT(infos[0].total_cores, 0); + for (int i = 1; i < SYSINFO_PARALLEL_CALLS; i++) { + ASSERT_EQ(infos[i].total_cores, infos[0].total_cores); + ASSERT_EQ(infos[i].perf_cores, infos[0].perf_cores); + ASSERT_EQ(infos[i].total_ram, infos[0].total_ram); + } + PASS(); +} + TEST(system_info_total_cores) { cbm_system_info_t info = cbm_system_info(); ASSERT(info.total_cores > 0); @@ -204,6 +232,50 @@ TEST(parallel_for_actually_parallel) { PASS(); } +typedef struct { + _Atomic int concurrent_max; + _Atomic int concurrent_now; + _Atomic int arrived; + int target_arrivals; +} concurrency_bound_ctx_t; + +static void concurrency_bound_worker(int idx, void *ctx_ptr) { + (void)idx; + enum { BOUND_SPINS = 5000000 }; + concurrency_bound_ctx_t *cc = ctx_ptr; + int cur = atomic_fetch_add(&cc->concurrent_now, 1) + 1; + atomic_fetch_add(&cc->arrived, 1); + + int prev_max = atomic_load(&cc->concurrent_max); + while (cur > prev_max) { + if (atomic_compare_exchange_weak(&cc->concurrent_max, &prev_max, cur)) { + break; + } + } + + for (int spins = 0; spins < BOUND_SPINS; spins++) { + if (atomic_load(&cc->arrived) >= cc->target_arrivals) { + break; + } + } + atomic_fetch_sub(&cc->concurrent_now, 1); +} + +TEST(parallel_for_max_workers_is_total_concurrency) { + enum { MAX_WORKERS = 2, ITERATIONS = 3 }; + concurrency_bound_ctx_t cc; + atomic_init(&cc.concurrent_max, 0); + atomic_init(&cc.concurrent_now, 0); + atomic_init(&cc.arrived, 0); + cc.target_arrivals = ITERATIONS; + + cbm_parallel_for_opts_t opts = {.max_workers = MAX_WORKERS, .force_pthreads = false}; + cbm_parallel_for(ITERATIONS, concurrency_bound_worker, &cc, opts); + + ASSERT_LTE(atomic_load(&cc.concurrent_max), MAX_WORKERS); + PASS(); +} + static void tls_worker(int idx, void *ctx_ptr) { (void)idx; static _Thread_local int tls_val = 0; @@ -330,6 +402,26 @@ TEST(parallel_for_no_duplicates) { PASS(); } +TEST(parallel_for_workers_above_count_visits_each_index_once) { + enum { + OVERWORKER_COUNT = 2, + OVERWORKER_MAX_WORKERS = 64, + }; + _Atomic int counts[OVERWORKER_COUNT]; + for (int i = 0; i < OVERWORKER_COUNT; i++) { + atomic_init(&counts[i], 0); + } + + cbm_parallel_for_opts_t opts = {.max_workers = OVERWORKER_MAX_WORKERS, + .force_pthreads = false}; + cbm_parallel_for(OVERWORKER_COUNT, count_visit_worker, counts, opts); + + for (int i = 0; i < OVERWORKER_COUNT; i++) { + ASSERT_EQ(atomic_load(&counts[i]), 1); + } + PASS(); +} + /* Helper for single_iteration_idx_zero test */ static int g_received_idx = -1; @@ -368,6 +460,7 @@ TEST(parallel_for_serial_matches_parallel) { /* ── Suite Registration ──────────────────────────────────────────── */ SUITE(system_info) { + RUN_TEST(system_info_parallel_first_call_consistent); RUN_TEST(system_info_total_cores); RUN_TEST(system_info_total_cores_sane); RUN_TEST(system_info_perf_cores); @@ -387,6 +480,7 @@ SUITE(worker_pool) { RUN_TEST(parallel_for_force_pthreads); RUN_TEST(parallel_for_per_slot_write); RUN_TEST(parallel_for_actually_parallel); + RUN_TEST(parallel_for_max_workers_is_total_concurrency); RUN_TEST(tls_persistence_across_dispatch); /* Resource management & edge cases */ RUN_TEST(parallel_for_negative_count); @@ -397,6 +491,7 @@ SUITE(worker_pool) { RUN_TEST(parallel_for_immediate_return_callback); RUN_TEST(parallel_for_context_passed_correctly); RUN_TEST(parallel_for_no_duplicates); + RUN_TEST(parallel_for_workers_above_count_visits_each_index_once); RUN_TEST(parallel_for_single_iteration_idx_zero); RUN_TEST(parallel_for_serial_matches_parallel); } diff --git a/tests/tsan.supp b/tests/tsan.supp new file mode 100644 index 000000000..8e219f022 --- /dev/null +++ b/tests/tsan.supp @@ -0,0 +1,10 @@ +# SQLite coordinates WAL-index readers and writers through shared-memory +# barriers plus OS file locks that ThreadSanitizer cannot observe. The vendored +# amalgamation already marks walIndexWriteHdr and walIndexTryHdr SQLITE_NO_TSAN +# for this protocol; macOS can attribute the matching read to walTryBeginRead. +# Suppress only that upstream protocol boundary so every CBM frame and all +# other SQLite functions remain instrumented. +# +# SQLite's explanation of this intentional WAL-index access pattern: +# https://sqlite.org/forum/info/2d74563d6c67bb0c +race:walTryBeginRead diff --git a/tests/windows/test_daemon_lifecycle.py b/tests/windows/test_daemon_lifecycle.py index 55ad85e66..42a02655d 100644 --- a/tests/windows/test_daemon_lifecycle.py +++ b/tests/windows/test_daemon_lifecycle.py @@ -88,6 +88,10 @@ def main(): print("RED: a warm cli one-shot should recycle the daemon without the " "cold-start hint:\n%s" % warm_text[:400]) return 1 + if "mem.allocator." in warm_text or "mem.init" in warm_text: + print("RED: a warm thin cli repeated daemon-owned allocator " + "initialization:\n%s" % warm_text[:400]) + return 1 status_active = run_cli(binary, cache, ["daemon", "status"]) status_text = output_text(status_active) if status_active.returncode != 0 or "permanent" not in status_text: