Skip to content

Commit 5cc74fb

Browse files
committed
feat(mcp): expose index freshness and relationship provenance
Signed-off-by: Vladimir Venegas Velásquez <vvenegasv@gmail.com>
1 parent 34efbc0 commit 5cc74fb

10 files changed

Lines changed: 530 additions & 16 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,17 @@ codebase-memory-mcp cli --raw search_graph '{"label": "Function"}' | jq '.result
382382
| `delete_project` | Remove a project and all its graph data. |
383383
| `index_status` | Check indexing status of a project. |
384384

385+
`index_status` includes an additive `evidence` object. `evidence.index_snapshot`
386+
reports the timestamp and Git HEAD captured when the last successful graph
387+
snapshot was finalized, the current HEAD, tracked working-tree state, and a
388+
`freshness` value: `current` means the indexed HEAD equals current HEAD and
389+
tracked files are clean; `head_changed` means a later checkout/commit changed
390+
HEAD; `working_tree_changed` means HEAD matches but tracked files are modified;
391+
`unknown` means Git or comparison data is unavailable. Untracked files are not
392+
compared. `evidence.coverage` reports discovered/indexed/excluded/failed file
393+
counters when the stored index contains them; older indexes may report
394+
`unknown` rather than guessing.
395+
385396
### Querying
386397

387398
| Tool | Description |
@@ -393,6 +404,14 @@ codebase-memory-mcp cli --raw search_graph '{"label": "Function"}' | jq '.result
393404
| `get_graph_schema` | Node/edge counts, relationship patterns, property definitions per label. Run this first. |
394405
| `get_code_snippet` | Read source code for a function by qualified name. |
395406
| `get_architecture` | Codebase overview: languages, packages, routes, hotspots, clusters, ADR. |
407+
408+
`trace_path`/`trace_call_path` preserve their existing `callers`/`callees`
409+
arrays and add `edge_evidence` for traversed relations when stored edge
410+
properties contain provenance. Relation `confidence` is source-resolution
411+
confidence from the indexer, not a probability of runtime correctness and not
412+
BM25/semantic search relevance. Dynamic behavior such as reflection,
413+
dependency injection, framework wiring, generated code, configuration, HTTP,
414+
async messaging, and cross-repo links may remain inferred or unavailable.
396415
| `search_code` | Grep-like text search within indexed project files. |
397416
| `manage_adr` | CRUD for Architecture Decision Records. |
398417
| `ingest_traces` | Ingest runtime traces to validate HTTP_CALLS edges. |

internal/cbm/sqlite_writer.c

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ static uint8_t *build_token_vec_record(const CBMDumpTokenVec *tv, int *out_len)
788788
return data;
789789
}
790790

791-
// Build a projects table record: (name, indexed_at, root_path)
791+
// Build a projects table record.
792792
static uint8_t *build_project_record(const char *name, const char *indexed_at,
793793
const char *root_path, int *out_len) {
794794
RecordBuilder r;
@@ -797,6 +797,13 @@ static uint8_t *build_project_record(const char *name, const char *indexed_at,
797797
rec_add_text(&r, name);
798798
rec_add_text(&r, indexed_at);
799799
rec_add_text(&r, root_path);
800+
/* indexed_git_head: unavailable in the direct writer path */
801+
rec_add_null(&r);
802+
/* files_discovered, files_indexed, files_excluded, files_failed */
803+
rec_add_int(&r, 0);
804+
rec_add_int(&r, 0);
805+
rec_add_int(&r, 0);
806+
rec_add_int(&r, 0);
800807

801808
uint8_t *data = rec_finalize(&r, out_len);
802809
rec_free(&r);
@@ -2113,7 +2120,10 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) {
21132120
MasterEntry master[] = {
21142121
{"table", "projects", "projects", projects_root,
21152122
"CREATE TABLE projects (\n\t\tname TEXT PRIMARY KEY,\n\t\tindexed_at TEXT NOT "
2116-
"NULL,\n\t\troot_path TEXT NOT NULL\n\t)"},
2123+
"NULL,\n\t\troot_path TEXT NOT NULL,\n\t\tindexed_git_head TEXT,\n\t\tfiles_discovered "
2124+
"INTEGER NOT NULL DEFAULT 0,\n\t\tfiles_indexed INTEGER NOT NULL DEFAULT 0,\n\t\t"
2125+
"files_excluded INTEGER NOT NULL DEFAULT 0,\n\t\tfiles_failed INTEGER NOT NULL DEFAULT "
2126+
"0\n\t)"},
21172127
{"index", "sqlite_autoindex_projects_1", "projects", autoindex_projects_root, NULL},
21182128
{"table", "file_hashes", "file_hashes", file_hashes_root,
21192129
"CREATE TABLE file_hashes (\n\t\tproject TEXT NOT NULL REFERENCES projects(name) ON "

src/git/git_context.c

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,42 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) {
266266
return 0;
267267
}
268268

269+
int cbm_git_tracked_dirty(const char *path, bool *out_dirty) {
270+
if (!out_dirty) {
271+
return CBM_NOT_FOUND;
272+
}
273+
*out_dirty = false;
274+
275+
if (!path || !git_validate_repo_path(path)) {
276+
return CBM_NOT_FOUND;
277+
}
278+
279+
char cmd[GIT_CMD_MAX];
280+
#ifdef _WIN32
281+
const char *null_dev = "NUL";
282+
#else
283+
const char *null_dev = "/dev/null";
284+
#endif
285+
int n = snprintf(cmd, sizeof(cmd), "git -C \"%s\" status --porcelain --untracked-files=no 2>%s",
286+
path, null_dev);
287+
if (n < 0 || n >= (int)sizeof(cmd)) {
288+
return CBM_NOT_FOUND;
289+
}
290+
291+
FILE *fp = cbm_popen(cmd, "r");
292+
if (!fp) {
293+
return CBM_NOT_FOUND;
294+
}
295+
char buf[GIT_OUTPUT_MAX];
296+
bool has_output = fgets(buf, sizeof(buf), fp) != NULL;
297+
int rc = cbm_pclose(fp);
298+
if (rc != 0) {
299+
return CBM_NOT_FOUND;
300+
}
301+
*out_dirty = has_output;
302+
return 0;
303+
}
304+
269305
char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx) {
270306
const char *project = project_name && project_name[0] ? project_name : "project";
271307
const char *slug = "working-tree";

src/git/git_context.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ typedef struct {
2121

2222
int cbm_git_context_resolve(const char *path, cbm_git_context_t *out);
2323
void cbm_git_context_free(cbm_git_context_t *ctx);
24+
/* Returns 0 when tracked working-tree dirtiness was determined, non-zero when
25+
* unavailable. Untracked files are intentionally not compared. */
26+
int cbm_git_tracked_dirty(const char *path, bool *out_dirty);
2427
char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx);
2528
int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size);
2629

src/mcp/mcp.c

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,85 @@ static void add_git_context_json(yyjson_mut_doc *doc, yyjson_mut_val *obj, const
896896
cbm_git_context_free(&ctx);
897897
}
898898

899+
static void add_index_evidence_json(yyjson_mut_doc *doc, yyjson_mut_val *root,
900+
const cbm_project_t *proj) {
901+
yyjson_mut_val *evidence = yyjson_mut_obj(doc);
902+
yyjson_mut_val *snap = yyjson_mut_obj(doc);
903+
const char *indexed_head = (proj && proj->indexed_git_head && proj->indexed_git_head[0])
904+
? proj->indexed_git_head
905+
: NULL;
906+
cbm_git_context_t ctx = {0};
907+
int git_rc = cbm_git_context_resolve(proj ? proj->root_path : NULL, &ctx);
908+
bool dirty = false;
909+
int dirty_rc = (proj && git_rc == 0 && ctx.is_git)
910+
? cbm_git_tracked_dirty(proj->root_path, &dirty)
911+
: CBM_NOT_FOUND;
912+
913+
add_git_context_string(doc, snap, "indexed_at", proj ? proj->indexed_at : NULL);
914+
add_git_context_string(doc, snap, "indexed_git_head", indexed_head);
915+
add_git_context_string(
916+
doc, snap, "current_git_head",
917+
(git_rc == 0 && ctx.is_git && ctx.head_sha && ctx.head_sha[0]) ? ctx.head_sha : NULL);
918+
const char *repo_state = "unavailable";
919+
if (git_rc == 0 && ctx.root_exists && !ctx.is_git) {
920+
repo_state = "not_git";
921+
} else if (git_rc == 0 && ctx.is_git && dirty_rc == 0) {
922+
repo_state = dirty ? "dirty" : "clean";
923+
}
924+
yyjson_mut_obj_add_str(doc, snap, "repository_state", repo_state);
925+
if (indexed_head && git_rc == 0 && ctx.is_git && ctx.head_sha && ctx.head_sha[0]) {
926+
yyjson_mut_obj_add_bool(doc, snap, "snapshot_matches_current_head",
927+
strcmp(indexed_head, ctx.head_sha) == 0);
928+
} else {
929+
yyjson_mut_obj_add_null(doc, snap, "snapshot_matches_current_head");
930+
}
931+
if (indexed_head && git_rc == 0 && ctx.is_git && ctx.head_sha && ctx.head_sha[0] &&
932+
dirty_rc == 0) {
933+
yyjson_mut_obj_add_bool(doc, snap, "snapshot_matches_working_tree",
934+
strcmp(indexed_head, ctx.head_sha) == 0 && !dirty);
935+
} else {
936+
yyjson_mut_obj_add_null(doc, snap, "snapshot_matches_working_tree");
937+
}
938+
const char *freshness = "unknown";
939+
if (indexed_head && git_rc == 0 && ctx.is_git && ctx.head_sha && ctx.head_sha[0] &&
940+
dirty_rc == 0) {
941+
if (strcmp(indexed_head, ctx.head_sha) != 0) {
942+
freshness = "head_changed";
943+
} else {
944+
freshness = dirty ? "working_tree_changed" : "current";
945+
}
946+
} else if (git_rc == 0 && ctx.root_exists && !ctx.is_git) {
947+
freshness = "unknown";
948+
}
949+
yyjson_mut_obj_add_str(doc, snap, "freshness", freshness);
950+
yyjson_mut_obj_add_val(doc, evidence, "index_snapshot", snap);
951+
952+
yyjson_mut_val *cov = yyjson_mut_obj(doc);
953+
int discovered = proj ? proj->files_discovered : 0;
954+
int indexed = proj ? proj->files_indexed : 0;
955+
int excluded = proj ? proj->files_excluded : 0;
956+
int failed = proj ? proj->files_failed : 0;
957+
yyjson_mut_obj_add_int(doc, cov, "files_discovered", discovered);
958+
yyjson_mut_obj_add_int(doc, cov, "files_indexed", indexed);
959+
yyjson_mut_obj_add_int(doc, cov, "files_excluded", excluded);
960+
yyjson_mut_obj_add_int(doc, cov, "files_failed", failed);
961+
yyjson_mut_obj_add_str(doc, cov, "coverage_status",
962+
failed > 0 || excluded > 0 ? "partial"
963+
: discovered > 0 && indexed == discovered ? "complete"
964+
: "unknown");
965+
yyjson_mut_obj_add_val(doc, evidence, "coverage", cov);
966+
yyjson_mut_val *limits = yyjson_mut_arr(doc);
967+
yyjson_mut_val *lim = yyjson_mut_obj(doc);
968+
yyjson_mut_obj_add_str(doc, lim, "code", "UNTRACKED_FILES_NOT_COMPARED");
969+
yyjson_mut_obj_add_str(doc, lim, "message",
970+
"Working-tree freshness compares current HEAD and tracked "
971+
"modifications; untracked files are not compared.");
972+
yyjson_mut_arr_add_val(limits, lim);
973+
yyjson_mut_obj_add_val(doc, evidence, "limitations", limits);
974+
yyjson_mut_obj_add_val(doc, root, "evidence", evidence);
975+
cbm_git_context_free(&ctx);
976+
}
977+
899978
/* Build a helpful error listing available projects. Caller must free() result. */
900979
static char *build_project_list_error(const char *reason) {
901980
char dir_path[CBM_SZ_1K];
@@ -1781,9 +1860,8 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
17811860
yyjson_mut_obj_add_strcpy(doc, root, "root_path",
17821861
proj_info.root_path ? proj_info.root_path : "");
17831862
add_git_context_json(doc, root, proj_info.root_path);
1784-
safe_str_free(&proj_info.name);
1785-
safe_str_free(&proj_info.indexed_at);
1786-
safe_str_free(&proj_info.root_path);
1863+
add_index_evidence_json(doc, root, &proj_info);
1864+
cbm_project_free_fields(&proj_info);
17871865
}
17881866
if (nodes == 0) {
17891867
yyjson_mut_obj_add_str(
@@ -2349,6 +2427,79 @@ static int pick_resolved_node(const cbm_node_t *nodes, int count, bool *ambiguou
23492427
return best;
23502428
}
23512429

2430+
static const char *edge_evidence_status(const char *strategy, double confidence, int candidates) {
2431+
if (!strategy || !strategy[0]) {
2432+
return "unavailable";
2433+
}
2434+
if (candidates > 1) {
2435+
return "ambiguous";
2436+
}
2437+
if (strstr(strategy, "heur") || strstr(strategy, "fuzzy") || confidence < 0.8) {
2438+
return "inferred";
2439+
}
2440+
return "verified";
2441+
}
2442+
2443+
static const char *edge_resolution_strategy(const char *strategy) {
2444+
if (!strategy || !strategy[0]) {
2445+
return "unknown";
2446+
}
2447+
if (strstr(strategy, "lsp")) {
2448+
return "hybrid_lsp";
2449+
}
2450+
if (strstr(strategy, "import") || strstr(strategy, "same_module") ||
2451+
strstr(strategy, "receiver")) {
2452+
return "direct_ast";
2453+
}
2454+
if (strstr(strategy, "fuzzy") || strstr(strategy, "heur")) {
2455+
return "heuristic";
2456+
}
2457+
return strategy;
2458+
}
2459+
2460+
static yyjson_mut_val *trace_edges_to_json_array(yyjson_mut_doc *doc, cbm_traverse_result_t *tr) {
2461+
yyjson_mut_val *arr = yyjson_mut_arr(doc);
2462+
for (int i = 0; i < tr->edge_count; i++) {
2463+
yyjson_mut_val *item = yyjson_mut_obj(doc);
2464+
yyjson_mut_obj_add_str(doc, item, "from",
2465+
tr->edges[i].from_name ? tr->edges[i].from_name : "");
2466+
yyjson_mut_obj_add_str(doc, item, "to", tr->edges[i].to_name ? tr->edges[i].to_name : "");
2467+
yyjson_mut_obj_add_str(doc, item, "type", tr->edges[i].type ? tr->edges[i].type : "");
2468+
yyjson_mut_val *edge = yyjson_mut_obj(doc);
2469+
const char *props = tr->edges[i].properties_json;
2470+
yyjson_doc *pdoc = props ? yyjson_read(props, strlen(props), 0) : NULL;
2471+
yyjson_val *proot = pdoc ? yyjson_doc_get_root(pdoc) : NULL;
2472+
yyjson_val *v = proot ? yyjson_obj_get(proot, "strategy") : NULL;
2473+
const char *strategy = yyjson_is_str(v) ? yyjson_get_str(v) : NULL;
2474+
v = proot ? yyjson_obj_get(proot, "confidence") : NULL;
2475+
bool has_conf = yyjson_is_num(v);
2476+
double conf = has_conf ? yyjson_get_num(v) : 0.0;
2477+
v = proot ? yyjson_obj_get(proot, "candidates") : NULL;
2478+
int candidates = yyjson_is_int(v) ? (int)yyjson_get_int(v) : 0;
2479+
yyjson_mut_obj_add_str(doc, edge, "resolution_strategy",
2480+
edge_resolution_strategy(strategy));
2481+
if (has_conf) {
2482+
yyjson_mut_obj_add_real(doc, edge, "confidence", conf);
2483+
} else {
2484+
yyjson_mut_obj_add_null(doc, edge, "confidence");
2485+
}
2486+
if (candidates > 0) {
2487+
yyjson_mut_obj_add_int(doc, edge, "candidate_count", candidates);
2488+
} else {
2489+
yyjson_mut_obj_add_null(doc, edge, "candidate_count");
2490+
}
2491+
yyjson_mut_obj_add_null(doc, edge, "source_location");
2492+
yyjson_mut_obj_add_str(doc, edge, "evidence_status",
2493+
edge_evidence_status(strategy, conf, candidates));
2494+
yyjson_mut_obj_add_val(doc, item, "edge", edge);
2495+
yyjson_mut_arr_add_val(arr, item);
2496+
if (pdoc) {
2497+
yyjson_doc_free(pdoc);
2498+
}
2499+
}
2500+
return arr;
2501+
}
2502+
23522503
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
23532504
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
23542505
char *project = cbm_mcp_get_string_arg(args, "project");
@@ -2486,6 +2637,16 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
24862637
yyjson_mut_obj_add_val(doc, root, "callers",
24872638
bfs_to_json_array(doc, &tr_in, risk_labels, include_tests));
24882639
}
2640+
yyjson_mut_val *edge_evidence = yyjson_mut_obj(doc);
2641+
if (do_outbound) {
2642+
yyjson_mut_obj_add_val(doc, edge_evidence, "outbound",
2643+
trace_edges_to_json_array(doc, &tr_out));
2644+
}
2645+
if (do_inbound) {
2646+
yyjson_mut_obj_add_val(doc, edge_evidence, "inbound",
2647+
trace_edges_to_json_array(doc, &tr_in));
2648+
}
2649+
yyjson_mut_obj_add_val(doc, root, "edge_evidence", edge_evidence);
24892650

24902651
/* Serialize BEFORE freeing traversal results (yyjson borrows strings) */
24912652
char *json = yy_doc_to_str(doc);

src/pipeline/pipeline.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,8 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
876876
stat_mtime_ns(&fst), fst.st_size);
877877
}
878878
}
879+
(void)cbm_store_update_project_coverage(hash_store, p->project_name, file_count, file_count,
880+
p->excluded_count, 0);
879881

880882
/* FTS5 backfill: populate nodes_fts with camelCase-split names.
881883
* Contentless FTS5 requires the special 'delete-all' command instead of

0 commit comments

Comments
 (0)