Files
deusdata--codebase-memory-mcp/tests/test_index_resilience.c
T
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

772 lines
29 KiB
C

/*
* test_index_resilience.c — Stage 2 / Track B guard.
*
* A file that fails during indexing (here: exceeds the env-configurable size
* cap) must be SKIPPED-AND-REPORTED, never silently dropped, and it must NOT
* take the rest of the repo down with it. This is the genuine guard for the
* error-surfacing wiring (has_error / read / oversized → cbm_file_error_t →
* MCP `skipped[]` + `skipped_count` + per-run logfile).
*
* These indexes run through the full production MCP `index_repository` flow.
* With only a handful of files the pipeline takes the SEQUENTIAL path
* (pass_definitions.c), so this exercises the sequential recording branch on
* every platform regardless of core count.
*/
#include "test_framework.h"
#include "repro_harness.h" /* RProj, rh_to_fwd_slashes, rh_count_label, rh_cleanup */
#include <yyjson/yyjson.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <limits.h>
/* glibc's _FORTIFY_SOURCE realpath() aborts unless the output buffer is at
* least PATH_MAX bytes, independent of the actual path length. */
#ifndef PATH_MAX
#define PATH_MAX 4096
#endif
/* Sleep before rewriting a fixture so its mtime_ns strictly increases and the
* incremental change classifier reliably sees the edit (10 ms). */
#define INCR_FIX_SLEEP_NS 10000000L
/* ── Local helpers ──────────────────────────────────────────────── */
static void ri_write_text(const char *dir, const char *name, const char *content) {
char path[700];
snprintf(path, sizeof(path), "%s/%s", dir, name);
FILE *f = fopen(path, "wb");
if (f) {
fputs(content, f);
fclose(f);
}
}
/* Write a comment-only python file padded well past `bytes`. The file is never
* parsed (the size cap rejects it before extraction), so its content only needs
* to make it a discoverable .py source that exceeds the cap. */
static void ri_write_big(const char *dir, const char *name, size_t bytes) {
char path[700];
snprintf(path, sizeof(path), "%s/%s", dir, name);
FILE *f = fopen(path, "wb");
if (!f) {
return;
}
static const char line[] = "# oversized filler line padding this file past the size cap\n";
size_t linelen = sizeof(line) - 1;
size_t written = 0;
while (written < bytes) {
fwrite(line, 1, linelen, f);
written += linelen;
}
fclose(f);
}
/* Slurp a whole file into a heap buffer (NUL-terminated). NULL on error. */
static char *ri_slurp(const char *path) {
FILE *f = fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long n = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (n < 0) {
(void)fclose(f);
return NULL;
}
char *buf = (char *)malloc((size_t)n + 1);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t rd = fread(buf, 1, (size_t)n, f);
(void)fclose(f);
buf[rd] = '\0';
return buf;
}
/* Index the files already written under lp->tmpdir through the production MCP
* flow, capturing the raw response. Returns the opened graph store (NULL on
* failure). Mirrors repro_harness.h's rh_open_indexed but keeps the response so
* we can assert on skipped_count / skipped[] / logfile. */
static cbm_store_t *ri_index_capture(RProj *lp, char **out_resp) {
lp->project = cbm_project_name_from_path(lp->tmpdir);
if (!lp->project) {
return NULL;
}
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);
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);
if (!lp->srv) {
return NULL;
}
char args[700];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", lp->tmpdir);
char *resp = cbm_mcp_handle_tool(lp->srv, "index_repository", args);
if (out_resp) {
*out_resp = resp;
} else if (resp) {
free(resp);
}
return cbm_store_open_path(lp->dbpath);
}
/* ── Tests ──────────────────────────────────────────────────────── */
/* INV(oversized-reported): with the size cap set LOW, indexing a repo that
* contains one > cap file plus two good files must:
* - complete with status "indexed" (a skip is a handled outcome, not failure),
* - report skipped_count >= 1 with the big file in skipped[] at phase "oversized",
* - write a per-run logfile (path echoed in the response) that lists the file,
* - and STILL index the two good files (their Function nodes are present).
*
* Guard property: on the UNWIRED code the big file is silently dropped — there
* is no skipped_count / skipped[] / logfile — so every assertion below fails.
*/
TEST(index_oversized_file_reported) {
RProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_resil_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
FAIL("mkdtemp failed");
}
rh_to_fwd_slashes(lp.tmpdir);
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
ri_write_text(lp.tmpdir, "good.go", "package main\n\nfunc beta() int { return 2 }\n");
ri_write_big(lp.tmpdir, "big.py", (size_t)2 * 1024 * 1024); /* ~2 MiB > 1 MiB cap */
char logpath[700];
snprintf(logpath, sizeof(logpath), "%s/skip.log", lp.tmpdir);
cbm_setenv("CBM_MAX_FILE_BYTES", "1048576", 1); /* 1 MiB cap */
cbm_setenv("CBM_INDEX_LOG", logpath, 1); /* deterministic logfile path */
char *resp = NULL;
cbm_store_t *store = ri_index_capture(&lp, &resp);
/* Unset env IMMEDIATELY (before any assert can bail) so a low cap never
* leaks into other tests in this process — cbm_max_file_bytes() reads env
* on every file. */
cbm_unsetenv("CBM_MAX_FILE_BYTES");
cbm_unsetenv("CBM_INDEX_LOG");
if (!resp) {
FAIL("no MCP response");
}
if (!store) {
free(resp);
FAIL("store did not open");
}
yyjson_doc *d = yyjson_read(resp, strlen(resp), 0);
ASSERT_NOT_NULL(d);
yyjson_val *sc = yyjson_obj_get(yyjson_doc_get_root(d), "structuredContent");
ASSERT_NOT_NULL(sc);
/* Status stays "indexed" — the skip is expected + handled. */
const char *status = yyjson_get_str(yyjson_obj_get(sc, "status"));
ASSERT_NOT_NULL(status);
ASSERT_STR_EQ("indexed", status);
/* At least one skip, surfaced at the top level. */
int skipped_count = yyjson_get_int(yyjson_obj_get(sc, "skipped_count"));
ASSERT_GTE(skipped_count, 1);
/* The big file is listed, at phase "oversized". */
yyjson_val *skipped = yyjson_obj_get(sc, "skipped");
ASSERT_NOT_NULL(skipped);
yyjson_val *files = yyjson_obj_get(skipped, "files");
ASSERT_NOT_NULL(files);
int found_big = 0;
size_t idx = 0;
size_t fmax = 0;
yyjson_val *fe = NULL;
yyjson_arr_foreach(files, idx, fmax, fe) {
const char *fp = yyjson_get_str(yyjson_obj_get(fe, "path"));
const char *phase = yyjson_get_str(yyjson_obj_get(fe, "phase"));
if (fp && strstr(fp, "big.py")) {
found_big = 1;
ASSERT_NOT_NULL(phase);
ASSERT_STR_EQ("oversized", phase);
}
}
ASSERT_TRUE(found_big);
/* A logfile was written, its path echoed, and it lists the skipped file. */
const char *logfile = yyjson_get_str(yyjson_obj_get(sc, "logfile"));
ASSERT_NOT_NULL(logfile);
ASSERT_STR_EQ(logpath, logfile);
char *logtext = ri_slurp(logfile);
ASSERT_NOT_NULL(logtext);
ASSERT_NOT_NULL(strstr(logtext, "big.py"));
ASSERT_NOT_NULL(strstr(logtext, "oversized"));
free(logtext);
/* The two good files ARE indexed — the skip did not take them down. */
int funcs = rh_count_label(store, lp.project, "Function");
ASSERT_GTE(funcs, 2);
yyjson_doc_free(d);
free(resp);
rh_cleanup(&lp, store);
PASS();
}
/* INV(clean-run): a run with no failures reports skipped_count == 0 and emits
* NO "skipped" object and NO "logfile" (a logfile is written only on skips). */
TEST(index_clean_run_no_logfile) {
RProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_resil_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
FAIL("mkdtemp failed");
}
rh_to_fwd_slashes(lp.tmpdir);
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
ri_write_text(lp.tmpdir, "good.go", "package main\n\nfunc beta() int { return 2 }\n");
/* Defensive: make sure no stray low cap / log override leaks in. */
cbm_unsetenv("CBM_MAX_FILE_BYTES");
cbm_unsetenv("CBM_INDEX_LOG");
char *resp = NULL;
cbm_store_t *store = ri_index_capture(&lp, &resp);
if (!resp) {
FAIL("no MCP response");
}
if (!store) {
free(resp);
FAIL("store did not open");
}
yyjson_doc *d = yyjson_read(resp, strlen(resp), 0);
ASSERT_NOT_NULL(d);
yyjson_val *sc = yyjson_obj_get(yyjson_doc_get_root(d), "structuredContent");
ASSERT_NOT_NULL(sc);
int skipped_count = yyjson_get_int(yyjson_obj_get(sc, "skipped_count"));
ASSERT_EQ(skipped_count, 0);
ASSERT_NULL(yyjson_obj_get(sc, "skipped"));
ASSERT_NULL(yyjson_obj_get(sc, "logfile"));
/* Clean parses → no parse-coverage flags either (#963). */
int pp_count = yyjson_get_int(yyjson_obj_get(sc, "parse_partial_count"));
ASSERT_EQ(pp_count, 0);
ASSERT_NULL(yyjson_obj_get(sc, "parse_partial"));
int funcs = rh_count_label(store, lp.project, "Function");
ASSERT_GTE(funcs, 2);
yyjson_doc_free(d);
free(resp);
rh_cleanup(&lp, store);
PASS();
}
/* INV(parse-partial-reported, #963): a file whose parse tree contains
* ERROR/MISSING regions (here: the preprocessor-blind #ifdef-split-brace C
* pattern) is INDEXED — not skipped — and the best-effort coverage signal
* surfaces on every layer:
* - skipped_count == 0 (a partial parse is NOT a skip),
* - parse_partial_count >= 1 with the file + its line ranges + the
* best-effort note in parse_partial{},
* - the per-run logfile lists it under phase "parse_partial",
* - the File node carries {"parse_incomplete":true,"error_ranges":...},
* - clean neighbors still extract.
*
* Guard property: on the unwired code there is no parse_partial_count /
* parse_partial[] / File-node marker — the file silently looks fully indexed.
*/
TEST(index_parse_partial_reported) {
RProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_resil_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
FAIL("mkdtemp failed");
}
rh_to_fwd_slashes(lp.tmpdir);
/* Both #ifdef branches open `guarded(...) {` sharing ONE close brace —
* brace-unbalanced for a preprocessor-blind parse → ERROR region. */
ri_write_text(lp.tmpdir, "split.c",
"void ok_before(void) { }\n"
"#ifdef FEATURE_A\n"
"static int guarded(int x) {\n"
"#else\n"
"static int guarded_alt(int x) {\n"
"#endif\n"
" return x + 1;\n"
"}\n");
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
char logpath[700];
snprintf(logpath, sizeof(logpath), "%s/coverage.log", lp.tmpdir);
cbm_setenv("CBM_INDEX_LOG", logpath, 1);
char *resp = NULL;
cbm_store_t *store = ri_index_capture(&lp, &resp);
cbm_unsetenv("CBM_INDEX_LOG");
if (!resp) {
FAIL("no MCP response");
}
if (!store) {
free(resp);
FAIL("store did not open");
}
yyjson_doc *d = yyjson_read(resp, strlen(resp), 0);
ASSERT_NOT_NULL(d);
yyjson_val *sc = yyjson_obj_get(yyjson_doc_get_root(d), "structuredContent");
ASSERT_NOT_NULL(sc);
/* Indexed, and NOT counted as a skip. */
const char *status = yyjson_get_str(yyjson_obj_get(sc, "status"));
ASSERT_NOT_NULL(status);
ASSERT_STR_EQ("indexed", status);
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(sc, "skipped_count")), 0);
/* The coverage signal is surfaced with ranges + the best-effort note. */
ASSERT_GTE(yyjson_get_int(yyjson_obj_get(sc, "parse_partial_count")), 1);
yyjson_val *pp = yyjson_obj_get(sc, "parse_partial");
ASSERT_NOT_NULL(pp);
yyjson_val *files = yyjson_obj_get(pp, "files");
ASSERT_NOT_NULL(files);
int found_split = 0;
size_t idx = 0;
size_t fmax = 0;
yyjson_val *fe = NULL;
yyjson_arr_foreach(files, idx, fmax, fe) {
const char *fp = yyjson_get_str(yyjson_obj_get(fe, "path"));
const char *ranges = yyjson_get_str(yyjson_obj_get(fe, "error_ranges"));
if (fp && strstr(fp, "split.c")) {
found_split = 1;
ASSERT_NOT_NULL(ranges);
ASSERT_GT((int)strlen(ranges), 0);
}
}
ASSERT_TRUE(found_split);
const char *note = yyjson_get_str(yyjson_obj_get(pp, "note"));
ASSERT_NOT_NULL(note);
ASSERT_NOT_NULL(strstr(note, "guarantee"));
/* Logfile lists it under the distinct phase. */
const char *logfile = yyjson_get_str(yyjson_obj_get(sc, "logfile"));
ASSERT_NOT_NULL(logfile);
char *logtext = ri_slurp(logfile);
ASSERT_NOT_NULL(logtext);
ASSERT_NOT_NULL(strstr(logtext, "parse_partial"));
ASSERT_NOT_NULL(strstr(logtext, "split.c"));
free(logtext);
/* The signal is persisted in the SEPARATE index_coverage table (never
* mixed into the graph tables) and reported by index_status,
* exactly as the index_repository tool description advertises. */
cbm_coverage_row_t *rows = NULL;
int cov_count = 0;
ASSERT_EQ(cbm_store_coverage_get(store, lp.project, &rows, &cov_count), CBM_STORE_OK);
int marked = 0;
for (int i = 0; i < cov_count; i++) {
if (rows[i].rel_path && strstr(rows[i].rel_path, "split.c")) {
ASSERT_NOT_NULL(rows[i].kind);
ASSERT_STR_EQ("parse_partial", rows[i].kind);
ASSERT_NOT_NULL(rows[i].detail);
ASSERT_GT((int)strlen(rows[i].detail), 0);
marked = 1;
}
}
cbm_store_free_coverage(rows, cov_count);
ASSERT_TRUE(marked);
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
char *qresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(qresp);
ASSERT_NOT_NULL(strstr(qresp, "split.c"));
ASSERT_NOT_NULL(strstr(qresp, "parse_partial"));
free(qresp);
/* get_code_snippet on a symbol from the flagged file carries the
* correlated coverage note (the result names its file, so the warning is
* precisely anchored); the note never fires for clean files. */
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"qualified_name\":\"ok_before\"}",
lp.project);
char *sresp = cbm_mcp_handle_tool(lp.srv, "get_code_snippet", qargs);
ASSERT_NOT_NULL(sresp);
ASSERT_NOT_NULL(strstr(sresp, "coverage_note"));
ASSERT_NOT_NULL(strstr(sresp, "PARTIALLY indexed"));
free(sresp);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"qualified_name\":\"alpha\"}", lp.project);
char *cresp2 = cbm_mcp_handle_tool(lp.srv, "get_code_snippet", qargs);
ASSERT_NOT_NULL(cresp2);
ASSERT_NULL(strstr(cresp2, "coverage_note")); /* good.py: no note */
free(cresp2);
/* The missed GRAPH: the same signal is queryable as a file-structure
* graph via query_graph(graph="missed") — exactly the query the tool
* description advertises — and it lives under the shadow project, so the
* REAL code graph gained no coverage rows. */
snprintf(qargs, sizeof(qargs),
"{\"project\":\"%s\",\"graph\":\"missed\",\"query\":\"MATCH (f:File) WHERE "
"f.kind = \\\"parse_partial\\\" RETURN f.file_path, f.detail\"}",
lp.project);
char *gresp = cbm_mcp_handle_tool(lp.srv, "query_graph", qargs);
ASSERT_NOT_NULL(gresp);
ASSERT_NOT_NULL(strstr(gresp, "split.c"));
free(gresp);
snprintf(qargs, sizeof(qargs),
"{\"project\":\"%s\",\"query\":\"MATCH (f:File) WHERE f.kind = "
"\\\"parse_partial\\\" RETURN f.file_path\"}",
lp.project);
char *cresp = cbm_mcp_handle_tool(lp.srv, "query_graph", qargs);
ASSERT_NOT_NULL(cresp);
ASSERT_NULL(strstr(cresp, "split.c")); /* code graph: no coverage rows */
free(cresp);
/* Clean neighbors still extract. */
int funcs = rh_count_label(store, lp.project, "Function");
ASSERT_GTE(funcs, 1);
yyjson_doc_free(d);
free(resp);
rh_cleanup(&lp, store);
PASS();
}
/* INV(parse-partial-clears-on-fix, #963): the persisted coverage signal must
* stay FRESH — after the broken file is fixed and the project re-indexed
* (incremental route: the DB already exists), its parse_partial row is gone
* and index_status reports it no longer. A stale flag on a fixed file
* would make the whole signal untrustworthy. */
TEST(index_parse_partial_clears_on_fix) {
RProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_resil_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
FAIL("mkdtemp failed");
}
rh_to_fwd_slashes(lp.tmpdir);
/* The #ifdef-split-brace pattern: an UNRECOVERED miss (the first branch's
* `guarded` never becomes a def), so the flag survives recovery
* subtraction. A `def broken(:`-style fixture would NOT work here — its
* def is recovered and the region is dropped. */
ri_write_text(lp.tmpdir, "flaky.c",
"void ok_before(void) { }\n"
"#ifdef A\n"
"static int guarded(int x) {\n"
"#else\n"
"static int guarded_alt(int x) {\n"
"#endif\n"
" return x + 1;\n"
"}\n");
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
char *resp = NULL;
cbm_store_t *store = ri_index_capture(&lp, &resp);
if (!resp) {
FAIL("no MCP response");
}
free(resp);
if (!store) {
FAIL("store did not open");
}
cbm_store_close(store);
/* Flagged after the first (full) index. */
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
char *cov1 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(cov1);
ASSERT_NOT_NULL(strstr(cov1, "flaky.c"));
free(cov1);
/* Fix the file; ensure a newer mtime so change detection can't miss it. */
struct timespec ts = {0, INCR_FIX_SLEEP_NS};
nanosleep(&ts, NULL);
ri_write_text(lp.tmpdir, "flaky.c",
"void ok_before(void) { }\n"
"static int fixed(int x) {\n"
" return x + 1;\n"
"}\n");
/* Re-index WITHOUT deleting the DB → routes through the incremental path. */
char iargs[700];
snprintf(iargs, sizeof(iargs), "{\"repo_path\":\"%s\"}", lp.tmpdir);
char *resp2 = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs);
ASSERT_NOT_NULL(resp2);
free(resp2);
char *cov2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(cov2);
ASSERT_NULL(strstr(cov2, "flaky.c"));
free(cov2);
store = cbm_store_open_path(lp.dbpath);
if (!store) {
FAIL("store did not reopen");
}
rh_cleanup(&lp, store);
PASS();
}
/* INV(not-indexed-by-design, #963): files/dirs dropped by ignore rules are a
* deliberate, deterministic class — reported as such and NEVER mixed into the
* failure surfaces:
* - response: "not_indexed_files" lists the gitignored file with its reason
* and the by-design note; "excluded" lists the ignored dir; the failure
* surfaces stay clean (skipped_count == 0, parse_partial_count == 0),
* - index_status: "not_indexed" carries dirs + files with the note,
* - the missed graph (failures only) does NOT contain them — the UI's
* report-an-edge-case callout must never fire for gitignored paths,
* - the persisted rows survive a re-index (the deleted-file prune must not
* eat them — deliberately-unindexed paths have no file_hashes rows). */
TEST(index_not_indexed_by_design_reported) {
RProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_resil_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
FAIL("mkdtemp failed");
}
rh_to_fwd_slashes(lp.tmpdir);
ri_write_text(lp.tmpdir, ".gitignore", "secret.py\ngenerated/\n");
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
ri_write_text(lp.tmpdir, "secret.py", "def hidden():\n return 42\n");
char gen_dir[700];
snprintf(gen_dir, sizeof(gen_dir), "%s/generated", lp.tmpdir);
cbm_mkdir(gen_dir);
ri_write_text(gen_dir, "gen.py", "def generated():\n return 3\n");
char *resp = NULL;
cbm_store_t *store = ri_index_capture(&lp, &resp);
if (!resp) {
FAIL("no MCP response");
}
if (!store) {
free(resp);
FAIL("store did not open");
}
yyjson_doc *d = yyjson_read(resp, strlen(resp), 0);
ASSERT_NOT_NULL(d);
yyjson_val *sc = yyjson_obj_get(yyjson_doc_get_root(d), "structuredContent");
ASSERT_NOT_NULL(sc);
/* By design ≠ failure: both failure surfaces stay clean. */
ASSERT_STR_EQ("indexed", yyjson_get_str(yyjson_obj_get(sc, "status")));
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(sc, "skipped_count")), 0);
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(sc, "parse_partial_count")), 0);
/* The gitignored FILE is listed with its reason + the by-design note. */
ASSERT_GTE(yyjson_get_int(yyjson_obj_get(sc, "not_indexed_files_count")), 1);
yyjson_val *ni = yyjson_obj_get(sc, "not_indexed_files");
ASSERT_NOT_NULL(ni);
yyjson_val *files = yyjson_obj_get(ni, "files");
ASSERT_NOT_NULL(files);
int found_secret = 0;
size_t idx = 0;
size_t fmax = 0;
yyjson_val *fe = NULL;
yyjson_arr_foreach(files, idx, fmax, fe) {
const char *fp = yyjson_get_str(yyjson_obj_get(fe, "path"));
const char *reason = yyjson_get_str(yyjson_obj_get(fe, "reason"));
if (fp && strcmp(fp, "secret.py") == 0) {
found_secret = 1;
ASSERT_NOT_NULL(reason);
ASSERT_STR_EQ("gitignore", reason);
}
}
ASSERT_TRUE(found_secret);
const char *note = yyjson_get_str(yyjson_obj_get(ni, "note"));
ASSERT_NOT_NULL(note);
ASSERT_NOT_NULL(strstr(note, "BY DESIGN"));
/* The gitignored DIR is in the excluded subtrees list. */
yyjson_val *excluded = yyjson_obj_get(sc, "excluded");
ASSERT_NOT_NULL(excluded);
char *excl_json = yyjson_val_write(excluded, 0, NULL);
ASSERT_NOT_NULL(excl_json);
ASSERT_NOT_NULL(strstr(excl_json, "generated"));
free(excl_json);
/* index_status carries the persisted by-design section. */
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
char *sresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(sresp);
ASSERT_NOT_NULL(strstr(sresp, "not_indexed"));
ASSERT_NOT_NULL(strstr(sresp, "secret.py"));
ASSERT_NOT_NULL(strstr(sresp, "generated"));
ASSERT_NOT_NULL(strstr(sresp, "BY DESIGN"));
free(sresp);
/* The missed graph shows FAILURES only — no by-design paths, so the UI
* callout can never fire for them. */
snprintf(qargs, sizeof(qargs),
"{\"project\":\"%s\",\"graph\":\"missed\",\"query\":\"MATCH (f) RETURN "
"f.file_path\"}",
lp.project);
char *gresp = cbm_mcp_handle_tool(lp.srv, "query_graph", qargs);
ASSERT_NOT_NULL(gresp);
ASSERT_NULL(strstr(gresp, "secret.py"));
ASSERT_NULL(strstr(gresp, "generated"));
free(gresp);
/* Re-index (DB exists → incremental route): the by-design rows survive
* the rebuild + deleted-file prune and stay fresh. */
char iargs[700];
snprintf(iargs, sizeof(iargs), "{\"repo_path\":\"%s\"}", lp.tmpdir);
char *resp2 = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs);
ASSERT_NOT_NULL(resp2);
free(resp2);
char *sresp2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(sresp2);
ASSERT_NOT_NULL(strstr(sresp2, "secret.py"));
ASSERT_NOT_NULL(strstr(sresp2, "generated"));
free(sresp2);
/* The ignored constructs are genuinely absent from the graph (label
* counts include <python-builtins> stubs, so assert by name). */
cbm_node_t *hits = NULL;
int hit_count = 0;
ASSERT_EQ(cbm_store_find_nodes_by_name(store, lp.project, "hidden", &hits, &hit_count),
CBM_STORE_OK);
ASSERT_EQ(hit_count, 0);
cbm_store_free_nodes(hits, hit_count);
hits = NULL;
hit_count = 0;
ASSERT_EQ(cbm_store_find_nodes_by_name(store, lp.project, "alpha", &hits, &hit_count),
CBM_STORE_OK);
ASSERT_GTE(hit_count, 1); /* the non-ignored neighbor IS indexed */
cbm_store_free_nodes(hits, hit_count);
yyjson_doc_free(d);
free(resp);
rh_cleanup(&lp, store);
PASS();
}
/* INV(relative-repo-path, #794): index_repository with repo_path "." must
* canonicalize to the absolute path BEFORE anything derives from it. On
* v0.8.1 the literal "." flowed through: the project was named "root", the
* projects table stored root_path="." verbatim, the post-dump integrity
* check flagged bad_root_path and AUTO-DELETED the whole DB — while the
* response still claimed status "indexed". The guard asserts the three
* things that failure mode breaks: a path-derived project name (not
* "root"), an absolute stored root_path, and a DB that persists and stays
* queryable after the run. */
TEST(index_relative_repo_path_canonicalized) {
#ifdef _WIN32
/* realpath/chdir are POSIX; the assertions are POSIX-path-shaped. The
* #794 canonicalization logic under guard is platform-independent, so
* the four Unix legs carry this regression guard. */
SKIP_PLATFORM("POSIX-only guard (realpath/chdir); #794 logic is platform-independent");
#else
RProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_resil_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
FAIL("mkdtemp failed");
}
rh_to_fwd_slashes(lp.tmpdir);
ri_write_text(lp.tmpdir, "good.py", "def alpha():\n return 1\n");
/* Resolve the canonical form of tmpdir (macOS: /tmp -> /private/tmp) so
* the derived-name and stored-root assertions compare like with like. */
char canon[PATH_MAX];
if (!realpath(lp.tmpdir, canon)) {
snprintf(canon, sizeof(canon), "%s", lp.tmpdir);
}
lp.project = cbm_project_name_from_path(canon);
if (!lp.project) {
FAIL("project name derivation failed");
}
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);
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);
if (!lp.srv) {
FAIL("server alloc failed");
}
/* chdir into the repo and index "." — restore cwd immediately after. */
char oldcwd[PATH_MAX];
if (!getcwd(oldcwd, sizeof(oldcwd))) {
FAIL("getcwd failed");
}
if (chdir(lp.tmpdir) != 0) {
FAIL("chdir failed");
}
char *resp = cbm_mcp_handle_tool(lp.srv, "index_repository", "{\"repo_path\":\".\"}");
int rc_back = chdir(oldcwd);
if (rc_back != 0) {
free(resp);
FAIL("chdir back failed");
}
ASSERT_NOT_NULL(resp);
/* The response names the path-derived project, never the literal-dot
* artifact "root". */
ASSERT_NULL(strstr(resp, "\"project\":\"root\""));
ASSERT_NOT_NULL(strstr(resp, lp.project));
ASSERT_NOT_NULL(strstr(resp, "\"status\":\"indexed\""));
free(resp);
/* The DB survived the run (v0.8.1 auto-deleted it here)... */
cbm_store_t *store = cbm_store_open_path(lp.dbpath);
if (!store) {
FAIL("db missing after relative-path index (auto-clean regression)");
}
/* ...with an ABSOLUTE canonical root_path in the projects table... */
cbm_project_t info = {0};
ASSERT_EQ(cbm_store_get_project(store, lp.project, &info), CBM_STORE_OK);
ASSERT_NOT_NULL(info.root_path);
ASSERT_TRUE(info.root_path[0] == '/');
ASSERT_STR_EQ(info.root_path, canon);
free((char *)info.name);
free((char *)info.indexed_at);
free((char *)info.root_path);
/* ...and it stays queryable. */
cbm_node_t *hits = NULL;
int hit_count = 0;
ASSERT_EQ(cbm_store_find_nodes_by_name(store, lp.project, "alpha", &hits, &hit_count),
CBM_STORE_OK);
ASSERT_GTE(hit_count, 1);
cbm_store_free_nodes(hits, hit_count);
rh_cleanup(&lp, store);
PASS();
#endif /* !_WIN32 */
}
SUITE(index_resilience) {
RUN_TEST(index_oversized_file_reported);
RUN_TEST(index_clean_run_no_logfile);
RUN_TEST(index_parse_partial_reported);
RUN_TEST(index_parse_partial_clears_on_fix);
RUN_TEST(index_not_indexed_by_design_reported);
RUN_TEST(index_relative_repo_path_canonicalized);
}