Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cbmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
internal/cbm/vendored/
vendored/
65 changes: 65 additions & 0 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,71 @@ int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_canc
return maintenance_cancelled && exit_status == EXIT_SUCCESS ? EXIT_FAILURE : exit_status;
}

/* One override, parsed strictly: a typo must not silently disable a gate, so
* anything that is not a whole number falls back to the documented default. */
static long cli_gate_threshold(const char *name, long fallback) {
const char *raw = getenv(name);
if (!raw || !raw[0]) {
return fallback;
}
char *end = NULL;
long value = strtol(raw, &end, 10);
if (end == raw || (end && *end != '\0')) {
return fallback;
}
return value;
}

int cbm_cli_index_exit_status(const char *result, int base_status) {
if (!result) {
return base_status;
}
yyjson_doc *envelope = yyjson_read(result, strlen(result), 0);
if (!envelope) {
return base_status;
}
yyjson_val *root = yyjson_doc_get_root(envelope);
yyjson_val *content = yyjson_is_obj(root) ? yyjson_obj_get(root, "content") : NULL;
yyjson_val *first = yyjson_is_arr(content) ? yyjson_arr_get_first(content) : NULL;
const char *text = first ? yyjson_get_str(yyjson_obj_get(first, "text")) : NULL;

int status = base_status;
yyjson_doc *payload = text ? yyjson_read(text, strlen(text), 0) : NULL;
yyjson_val *proot = payload ? yyjson_doc_get_root(payload) : NULL;
if (yyjson_is_obj(proot)) {
const char *state = yyjson_get_str(yyjson_obj_get(proot, "status"));
const char *reason = yyjson_get_str(yyjson_obj_get(proot, "reason"));
if (state && strcmp(state, "error") == 0) {
status = (reason && strcmp(reason, "target_unavailable") == 0)
? CBM_CLI_EXIT_TARGET
: (base_status != CBM_CLI_EXIT_OK ? base_status : CBM_CLI_EXIT_FAILURE);
} else if (base_status == CBM_CLI_EXIT_OK) {
long unusable = (long)yyjson_get_int(yyjson_obj_get(proot, "parse_unusable_count"));
long partial = (long)yyjson_get_int(yyjson_obj_get(proot, "parse_partial_count"));
long files = (long)yyjson_get_int(yyjson_obj_get(proot, "files_indexed"));
long max_unusable = cli_gate_threshold("CBM_GATE_MAX_UNUSABLE", 0);
long max_partial_pct = cli_gate_threshold("CBM_GATE_MAX_PARTIAL_PCT", 10);
/* "degraded" is the pipeline's own verdict that the graph came out
* far smaller than the run expected — a quality failure by any
* reading, so it joins the two parse thresholds. */
bool degraded = strcmp(state ? state : "", "degraded") == 0;
bool too_many_unusable = max_unusable >= 0 && unusable > max_unusable;
/* Integer arithmetic on purpose: a percentage compared through a
* double would make the threshold depend on rounding. */
bool too_many_partial =
max_partial_pct >= 0 && files > 0 && partial * 100 > max_partial_pct * files;
if (degraded || too_many_unusable || too_many_partial) {
status = CBM_CLI_EXIT_QUALITY;
}
}
}
if (payload) {
yyjson_doc_free(payload);
}
yyjson_doc_free(envelope);
return status;
}

/* #1537. Two very different failures reached this one message: the cohort was
* BUSY (real sessions are running — the reader can close them), or the
* reservation failed outright (lock I/O, stale coordination state, permissions
Expand Down
38 changes: 38 additions & 0 deletions src/cli/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,44 @@ bool cbm_cli_mcp_result_is_error(const char *result);
* accepted cancellation into EXIT_FAILURE. */
int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_cancelled);

/* ── Index exit contract ──────────────────────────────────────────
*
* A freshness gate must not have to read anyone's account of itself: it reads
* the process status. `index_repository` therefore grades its own outcome.
*
* 0 indexed, quality at or above the thresholds
* 1 hard failure (pipeline fell over inside a reachable repository)
* 2 indexed, but below a quality threshold — a graph that exists and lies
* about how much of the tree it covers is worse than no graph
* 3 target unavailable: repo_path is absent, unreadable, or not a directory
*
* 1 keeps its historical meaning so existing `|| fail` scripts are unaffected;
* 2 and 3 are new and carve out cases that used to return 0 and 1.
*
* Thresholds, both overridable by environment:
* CBM_GATE_MAX_UNUSABLE (default 0) absolute count of unparsable files
* CBM_GATE_MAX_PARTIAL_PCT (default 10) parse_partial_count / files_indexed
* A threshold set to a negative value disables that check.
*
* The two numbers differ on purpose and must not be collapsed into one: a file
* that did not parse at all is a defect and gets no tolerance, while partial
* parsing marks constructs this grammar does not cover — a property of language
* support, not of index quality, and normal at a few percent in a large tree.
* A gate its own repository cannot pass gets switched off, which protects
* nothing. */
enum {
CBM_CLI_EXIT_OK = 0,
CBM_CLI_EXIT_FAILURE = 1,
CBM_CLI_EXIT_QUALITY = 2,
CBM_CLI_EXIT_TARGET = 3,
};

/* Grade an index_repository result envelope. `base_status` is what the
* ordinary isError mapping already produced; it is preserved unless the
* payload justifies a more specific code. Unparsable payloads change
* nothing — silence is never upgraded into a verdict. */
int cbm_cli_index_exit_status(const char *result, int base_status);

/* ── Self-update: version comparison ──────────────────────────── */

/* Compare two semver strings (e.g. "0.2.1" vs "0.2.0").
Expand Down
5 changes: 5 additions & 0 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,11 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
} else {
exit_code = cli_print_mcp_result(result);
}
/* One place for both presentations: a gate reading the status must get
* the same verdict whether or not the caller asked for --json. */
if (tool_name && strcmp(tool_name, "index_repository") == 0) {
exit_code = cbm_cli_index_exit_status(result, exit_code);
}
exit_code = cbm_cli_exit_status_after_maintenance(exit_code, maintenance_cancelled);
if (cbm_index_worker_active()) {
/* The supervisor protocol classifies the PROCESS, not the tool
Expand Down
82 changes: 77 additions & 5 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -6760,6 +6760,15 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
add_git_context_json(doc, root, proj_info.root_path);
}
}
/* Freshness gates compare this against their own record of the last
* edit. indexed_at only says when indexing RAN; the store generation
* advances on every graph mutation, so it is the honest answer to
* "is this graph newer than my change". Deliberately NOT the coverage
* block's meta.generation, which is a copy of indexed_at. */
char generation[QUERY_GRAPH_GENERATION_CAP] = "";
if (cbm_store_generation(store, generation, sizeof(generation)) == CBM_STORE_OK) {
yyjson_mut_obj_add_strcpy(doc, root, "graph_generation", generation);
}
add_coverage_report(doc, root, store, project, have_proj_info ? proj_info.indexed_at : NULL,
coverage_samples);
safe_str_free(&proj_info.name);
Expand Down Expand Up @@ -10316,6 +10325,28 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *
add_parse_partial_summary(doc, root, file_errors, file_error_count);
add_parse_unusable_summary(doc, root, file_errors, file_error_count);
}
/* Denominator for quality gates. parse_partial_count on its own cannot
* say whether sixty flagged files are a rounding error or half the
* repository; file_hashes holds exactly one row per indexed file. */
if (store) {
cbm_file_hash_t *hashes = NULL;
int hash_count = 0;
if (cbm_store_get_file_hashes(store, project_name, &hashes, &hash_count) == CBM_STORE_OK) {
yyjson_mut_obj_add_int(doc, root, "files_indexed", hash_count);
/* The share, stated outright. A gate that only passes or fails
* cannot tell anyone HOW partial the parse was, and "the graph is
* fresh, but 6.2% of files parsed partially" is a usable hint
* where a bare verdict is not. Tenths of a percent are derived by
* integer division so the number never depends on rounding. */
yyjson_mut_val *partial_val = yyjson_mut_obj_get(root, "parse_partial_count");
if (partial_val && hash_count > 0) {
int partial = yyjson_mut_get_int(partial_val);
long tenths = (long)partial * 1000 / hash_count;
yyjson_mut_obj_add_real(doc, root, "parse_partial_pct", (double)tenths / 10.0);
}
cbm_store_free_file_hashes(hashes, hash_count);
}
}
int nodes = 0;
int edges = 0;
bool degraded = false;
Expand Down Expand Up @@ -11211,6 +11242,16 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
}
} else {
yyjson_mut_obj_add_str(doc, root, "status", "error");
/* A repository that is not there at all is a different failure from a
* pipeline that fell over inside one that is — the hint below reads
* identically for both, so callers could not tell them apart. The
* split is deliberately about the ROOT: an unreadable subtree is a
* pipeline failure, not a missing target. */
cbm_path_info_t target = {0};
bool target_reachable =
cbm_path_info_utf8(repo_path, &target) == CBM_PATH_INFO_OK && target.is_directory;
yyjson_mut_obj_add_str(doc, root, "reason",
target_reachable ? "pipeline_failed" : "target_unavailable");
yyjson_mut_obj_add_str(doc, root, "hint",
"Pipeline failed. Check repo_path exists and contains source files. "
"Try mode='fast' for a quicker diagnostic run.");
Expand Down Expand Up @@ -11731,13 +11772,16 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node,
node->qualified_name ? node->qualified_name : "");
yyjson_mut_obj_add_str(doc, root_obj, "label", node->label ? node->label : "");

const char *display_path = "";
/* file_path is the graph's own relative path in every tool, so a caller
* can key a record on it without knowing which tool produced it. This
* field used to flip to an absolute path whenever the source happened to
* be read from disk — the same symbol answered differently depending on
* source_mode. The absolute form is still useful for opening the file, so
* it moved to its own field instead of overwriting this one. */
yyjson_mut_obj_add_str(doc, root_obj, "file_path", node->file_path ? node->file_path : "");
if (abs_path) {
display_path = abs_path;
} else if (node->file_path) {
display_path = node->file_path;
yyjson_mut_obj_add_str(doc, root_obj, "absolute_path", abs_path);
}
yyjson_mut_obj_add_str(doc, root_obj, "file_path", display_path);
yyjson_mut_obj_add_int(doc, root_obj, "start_line", start);
yyjson_mut_obj_add_int(doc, root_obj, "end_line", end);
if (snippet_clipped) {
Expand Down Expand Up @@ -16378,6 +16422,34 @@ render_detect_output:;
}
yyjson_mut_obj_add_val(doc, root_obj, "changed_files", cf);
yyjson_mut_obj_add_int(doc, root_obj, "seed_symbols", seed_count);
/* The seeds themselves, not just how many: a consumer that marks
* recorded facts stale needs the symbol, and a file-level answer
* would stale every fact in the file. Bounded like the module rollup;
* seed_symbols above stays the exact total. */
if (seed_count > 0) {
enum { DETECT_SEEDCAP = 256 };
int seed_shown = seed_count < DETECT_SEEDCAP ? seed_count : DETECT_SEEDCAP;
int seed_emitted = 0;
yyjson_mut_val *sl = yyjson_mut_arr(doc);
for (int i = 0; i < seed_shown; i++) {
cbm_node_t seed_node = {0};
if (cbm_store_find_node_by_id(store, seeds[i], &seed_node) != CBM_STORE_OK) {
continue;
}
yyjson_mut_val *so = yyjson_mut_obj(doc);
yyjson_mut_obj_add_strcpy(doc, so, "qn",
seed_node.qualified_name ? seed_node.qualified_name : "");
yyjson_mut_obj_add_strcpy(doc, so, "label",
seed_node.label ? seed_node.label : "");
yyjson_mut_obj_add_strcpy(doc, so, "file",
seed_node.file_path ? seed_node.file_path : "");
yyjson_mut_arr_add_val(sl, so);
seed_emitted++;
cbm_node_free_fields(&seed_node);
}
yyjson_mut_obj_add_val(doc, root_obj, "seed_symbols_list", sl);
yyjson_mut_obj_add_int(doc, root_obj, "seed_symbols_shown", seed_emitted);
}
yyjson_mut_obj_add_int(doc, root_obj, "impacted_total", impact.visited_count);
yyjson_mut_obj_add_str(doc, root_obj, "impacted_total_relation",
engine_saturated ? "gte" : "eq");
Expand Down
Loading
Loading