mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-07 08:43:35 +08:00
index_repository silently dropped files that failed to index: CBMFileResult.has_error
was set but never read, oversized files (>100 MB) were dropped with no signal, and
read/extract failures only bumped a logged-only counter — a file that couldn't be
indexed just vanished from the graph.
Collect and surface them:
- New retained {path, reason, phase} error list on struct cbm_pipeline (mirrors the
excluded_dirs pattern) + accessor + a back-pointer on cbm_pipeline_ctx_t so both
extraction paths can append. Wired in BOTH the sequential (pass_definitions) and
parallel (per-worker, merged lock-free) paths — small repos take the sequential
path, so wiring only one would leave the guard vacuous.
- Feeds: read-fail, extract-fail, the newly-CONSUMED has_error (parse timeout /
parse failed, with error_msg), and oversized. The cross_lsp phase is reserved for
the crash supervisor (Track C) and not fed here (the cross-LSP passes are
best-effort with no failure return; feeding the no-source case would be false
positives).
- MCP/CLI response gains top-level "skipped_count" (0 on clean) and, when >0, a
capped "skipped":{files[<=50],count,truncated} + "logfile". Status stays "indexed"
— a reported skip is the expected, handled outcome, not a degradation.
- Per-run logfile (full uncapped list) written ONLY when >=1 file is skipped:
$CBM_INDEX_LOG override else <cache_dir>/logs/<project>-<epoch>.log.
- Generous env-configurable caps (src/foundation/limits.c): CBM_MAX_FILE_BYTES,
default raised 100 MB -> 512 MiB; over-cap files are REPORTED (phase oversized) +
WARNed, never silently dropped.
Reproduce-first: tests/test_index_resilience.c (gating) — an oversized file (cap
lowered via env) must appear in skipped[] with the 2 good files still indexed and a
logfile written; a clean run has skipped_count 0 and no logfile. Genuine guard:
no-op'ing the recorder makes the oversized file silently vanish (RED). Full suite
5750/0, no ASan/UBSan.
Part of the resilient-indexing effort (Track B surfacing layer). Refs #668.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
264 lines
9.2 KiB
C
264 lines
9.2 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>
|
|
|
|
/* ── 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"));
|
|
|
|
int funcs = rh_count_label(store, lp.project, "Function");
|
|
ASSERT_GTE(funcs, 2);
|
|
|
|
yyjson_doc_free(d);
|
|
free(resp);
|
|
rh_cleanup(&lp, store);
|
|
PASS();
|
|
}
|
|
|
|
SUITE(index_resilience) {
|
|
RUN_TEST(index_oversized_file_reported);
|
|
RUN_TEST(index_clean_run_no_logfile);
|
|
}
|