diff --git a/.cbmignore b/.cbmignore new file mode 100644 index 000000000..b235f7519 --- /dev/null +++ b/.cbmignore @@ -0,0 +1,2 @@ +internal/cbm/vendored/ +vendored/ diff --git a/src/cli/cli.c b/src/cli/cli.c index 249b43e6f..eb462da51 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -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 diff --git a/src/cli/cli.h b/src/cli/cli.h index 139221f0a..ffa1a40fe 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -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"). diff --git a/src/main.c b/src/main.c index 8a575c44f..0173a6ab6 100644 --- a/src/main.c +++ b/src/main.c @@ -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 diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7aa40605c..259cb5b5f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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); @@ -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; @@ -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."); @@ -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) { @@ -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"); diff --git a/tests/test_cli.c b/tests/test_cli.c index a95b4a84f..d7c172178 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14357,6 +14357,277 @@ TEST(cli_update_only_names_an_installer_that_exists_issue1632) { PASS(); } +/* ── index_repository exit contract ────────────────────────────── + * + * cbm_cli_index_exit_status grades a result envelope into a process exit + * code, because a freshness gate reads process status rather than a tool's + * account of itself. The six outcomes were measured by hand against the + * built binary when the contract landed; measured once is not pinned, and + * a code that moves silently is exactly what the contract exists to stop. */ + +/* Build the envelope the CLI actually receives: the payload travels as a + * JSON string inside content[0].text. Escaping it by hand in every test + * would put the test's own escaping on trial instead of the grader. */ +static char *cli_index_envelope(const char *payload) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) { + return NULL; + } + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_val *content = yyjson_mut_arr(doc); + yyjson_mut_val *item = yyjson_mut_obj(doc); + yyjson_mut_obj_add_str(doc, item, "text", payload); + yyjson_mut_arr_append(content, item); + yyjson_mut_obj_add_val(doc, root, "content", content); + char *json = yyjson_mut_write(doc, 0, NULL); + yyjson_mut_doc_free(doc); + return json; +} + +/* Grade one payload. Returns -1 only if the envelope could not be built, a + * value no exit code uses, so a setup failure cannot pass for a verdict. */ +static int cli_index_grade(const char *payload, int base_status) { + char *envelope = cli_index_envelope(payload); + if (!envelope) { + return -1; + } + int status = cbm_cli_index_exit_status(envelope, base_status); + free(envelope); + return status; +} + +typedef struct { + char *unusable; + char *partial_pct; +} cli_gate_env_t; + +/* A test that means to exercise the documented defaults must not inherit + * whatever the developer happens to have exported. */ +static cli_gate_env_t cli_gate_env_clear(void) { + cli_gate_env_t saved = {save_test_env("CBM_GATE_MAX_UNUSABLE"), + save_test_env("CBM_GATE_MAX_PARTIAL_PCT")}; + cbm_unsetenv("CBM_GATE_MAX_UNUSABLE"); + cbm_unsetenv("CBM_GATE_MAX_PARTIAL_PCT"); + return saved; +} + +static void cli_gate_env_restore(cli_gate_env_t saved) { + restore_test_env("CBM_GATE_MAX_UNUSABLE", saved.unusable); + restore_test_env("CBM_GATE_MAX_PARTIAL_PCT", saved.partial_pct); +} + +/* A clean index keeps the code it always had: the contract adds verdicts, + * it does not make previously good runs start failing. */ +TEST(cli_index_exit_clean_run_stays_zero) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":4," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A file that did not parse at all gets no tolerance — the default is zero, + * so one such file is already a quality failure. */ +TEST(cli_index_exit_unusable_file_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":5," + "\"parse_partial_count\":0,\"parse_unusable_count\":1}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* Partial parsing is graded as a share, not a count: 20 of 100 files is + * twice the default ceiling. */ +TEST(cli_index_exit_partial_above_threshold_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* The boundary itself passes. The comparison is strict and integral on + * purpose: at exactly the documented ceiling a double could round either + * way, and a threshold whose verdict depends on rounding is not a + * threshold. 10 of 100 is the ceiling, and the ceiling is allowed. */ +TEST(cli_index_exit_partial_exactly_at_threshold_passes) { + cli_gate_env_t saved = cli_gate_env_clear(); + int at_ceiling = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":10,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + /* One file more is over it, which proves the case above is the boundary + * and not simply a check that never fires. */ + int over_ceiling = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":11,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(at_ceiling, CBM_CLI_EXIT_OK); + ASSERT_EQ(over_ceiling, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* "degraded" is the pipeline's own verdict that the graph came out far + * smaller than the run expected. It carries no parse counts, so it has to + * be graded on the status alone. */ +TEST(cli_index_exit_degraded_status_is_a_quality_failure) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"degraded\",\"files_indexed\":40," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* An absent target and a pipeline that fell over inside a reachable + * repository used to share code 1, which is the whole reason the contract + * was written: the caller could not tell "wrong path" from "broken run". */ +TEST(cli_index_exit_separates_missing_target_from_broken_run) { + cli_gate_env_t saved = cli_gate_env_clear(); + int target = cli_index_grade("{\"status\":\"error\",\"reason\":\"target_unavailable\"}", + CBM_CLI_EXIT_FAILURE); + int pipeline = cli_index_grade("{\"status\":\"error\",\"reason\":\"pipeline_failed\"}", + CBM_CLI_EXIT_FAILURE); + /* An error the mapping had not already flagged still has to land on a + * failure code rather than fall through as success. */ + int unflagged = cli_index_grade("{\"status\":\"error\",\"reason\":\"pipeline_failed\"}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(target, CBM_CLI_EXIT_TARGET); + ASSERT_EQ(pipeline, CBM_CLI_EXIT_FAILURE); + ASSERT_EQ(unflagged, CBM_CLI_EXIT_FAILURE); + PASS(); +} + +/* Silence is never upgraded into a verdict. A payload the grader cannot + * read says nothing about index quality, and inventing a 2 from it would + * fail runs for the crime of an unexpected response shape. */ +TEST(cli_index_exit_never_upgrades_silence) { + cli_gate_env_t saved = cli_gate_env_clear(); + ASSERT_EQ(cbm_cli_index_exit_status(NULL, CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cbm_cli_index_exit_status("", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cbm_cli_index_exit_status("not json at all", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + /* A well-formed envelope carrying no content, and one whose text is not + * itself JSON — both are shapes a future response could take. */ + ASSERT_EQ(cbm_cli_index_exit_status("{\"content\":[]}", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + ASSERT_EQ(cli_index_grade("plain text, not a payload", CBM_CLI_EXIT_OK), CBM_CLI_EXIT_OK); + /* And the same shapes must not erase a failure already established. */ + ASSERT_EQ(cbm_cli_index_exit_status(NULL, CBM_CLI_EXIT_FAILURE), CBM_CLI_EXIT_FAILURE); + cli_gate_env_restore(saved); + PASS(); +} + +/* Grading only ever makes a code more specific. A base failure survives a + * payload that looks perfectly healthy, because the transport already knew + * something the payload does not say. */ +TEST(cli_index_exit_does_not_downgrade_a_failing_base) { + cli_gate_env_t saved = cli_gate_env_clear(); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":9," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_FAILURE); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_FAILURE); + PASS(); +} + +/* Both thresholds are overridable, and both are read at grading time. */ +TEST(cli_index_exit_thresholds_read_the_environment) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; + const char *three_unusable = "{\"status\":\"ok\",\"files_indexed\":50," + "\"parse_partial_count\":0,\"parse_unusable_count\":3}"; + + /* Positive control: at the defaults both payloads fail, so a pass below + * is the override working and not the check being absent. */ + int partial_default = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + int unusable_default = cli_index_grade(three_unusable, CBM_CLI_EXIT_OK); + + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "50", 1); + cbm_setenv("CBM_GATE_MAX_UNUSABLE", "5", 1); + int partial_raised = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + int unusable_raised = cli_index_grade(three_unusable, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + + ASSERT_EQ(partial_default, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(unusable_default, CBM_CLI_EXIT_QUALITY); + ASSERT_EQ(partial_raised, CBM_CLI_EXIT_OK); + ASSERT_EQ(unusable_raised, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A negative ceiling switches its own check off. This is the documented + * escape hatch for a repository whose grammars are known to be thin. */ +TEST(cli_index_exit_negative_threshold_disables_the_check) { + cli_gate_env_t saved = cli_gate_env_clear(); + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "-1", 1); + cbm_setenv("CBM_GATE_MAX_UNUSABLE", "-1", 1); + int status = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":99,\"parse_unusable_count\":7}", + CBM_CLI_EXIT_OK); + /* Disabling the parse checks must not disable the pipeline's own + * verdict: "degraded" is not a threshold and has no off switch. */ + int degraded = cli_index_grade("{\"status\":\"degraded\",\"files_indexed\":10," + "\"parse_partial_count\":0,\"parse_unusable_count\":0}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(status, CBM_CLI_EXIT_OK); + ASSERT_EQ(degraded, CBM_CLI_EXIT_QUALITY); + PASS(); +} + +/* A typo must not silently disable a gate. Anything that is not a whole + * number falls back to the documented default, so "1O" (letter O) fails the + * run it would have failed anyway instead of quietly waving it through. */ +TEST(cli_index_exit_unreadable_threshold_falls_back_to_default) { + cli_gate_env_t saved = cli_gate_env_clear(); + const char *twenty_percent = "{\"status\":\"ok\",\"files_indexed\":100," + "\"parse_partial_count\":20,\"parse_unusable_count\":0}"; + const char *unreadable[] = {"abc", "1O", "10pct", " 10", "10 ", "", "1e1", "10.0"}; + for (size_t i = 0; i < sizeof(unreadable) / sizeof(unreadable[0]); i++) { + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", unreadable[i], 1); + int status = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + if (status != CBM_CLI_EXIT_QUALITY) { + printf(" unreadable threshold \"%s\" gave exit %d\n", unreadable[i], status); + } + ASSERT_EQ(status, CBM_CLI_EXIT_QUALITY); + } + /* A value that DOES read still takes effect, so the loop above is about + * unreadable text and not about the override being ignored outright. */ + cbm_setenv("CBM_GATE_MAX_PARTIAL_PCT", "50", 1); + int readable = cli_index_grade(twenty_percent, CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(readable, CBM_CLI_EXIT_OK); + PASS(); +} + +/* A share needs a denominator. Without files_indexed the percentage cannot + * be computed at all, and a run must not be failed on a number nobody + * could work out. */ +TEST(cli_index_exit_partial_without_denominator_is_not_graded) { + cli_gate_env_t saved = cli_gate_env_clear(); + int no_denominator = cli_index_grade("{\"status\":\"ok\",\"parse_partial_count\":20}", + CBM_CLI_EXIT_OK); + int zero_denominator = cli_index_grade("{\"status\":\"ok\",\"files_indexed\":0," + "\"parse_partial_count\":20}", + CBM_CLI_EXIT_OK); + /* An unusable file is an absolute count and still grades without one. */ + int unusable_still_graded = cli_index_grade("{\"status\":\"ok\",\"parse_unusable_count\":1}", + CBM_CLI_EXIT_OK); + cli_gate_env_restore(saved); + ASSERT_EQ(no_denominator, CBM_CLI_EXIT_OK); + ASSERT_EQ(zero_denominator, CBM_CLI_EXIT_OK); + ASSERT_EQ(unusable_still_graded, CBM_CLI_EXIT_QUALITY); + PASS(); +} + SUITE(cli) { if (!th_secure_runtime_parent_new(g_cli_suite_runtime_parent, sizeof(g_cli_suite_runtime_parent), "cli-suite")) { @@ -14771,6 +15042,20 @@ SUITE(cli) { /* Stdin argument gate (#1359) */ RUN_TEST(cli_zero_argument_tool_never_reads_stdin_issue1359); RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359); + + /* index_repository exit contract */ + RUN_TEST(cli_index_exit_clean_run_stays_zero); + RUN_TEST(cli_index_exit_unusable_file_is_a_quality_failure); + RUN_TEST(cli_index_exit_partial_above_threshold_is_a_quality_failure); + RUN_TEST(cli_index_exit_partial_exactly_at_threshold_passes); + RUN_TEST(cli_index_exit_degraded_status_is_a_quality_failure); + RUN_TEST(cli_index_exit_separates_missing_target_from_broken_run); + RUN_TEST(cli_index_exit_never_upgrades_silence); + RUN_TEST(cli_index_exit_does_not_downgrade_a_failing_base); + RUN_TEST(cli_index_exit_thresholds_read_the_environment); + RUN_TEST(cli_index_exit_negative_threshold_disables_the_check); + RUN_TEST(cli_index_exit_unreadable_threshold_falls_back_to_default); + RUN_TEST(cli_index_exit_partial_without_denominator_is_not_graded); cbm_cli_set_activation_runtime_parent_for_test(NULL); test_rmdir_r(g_cli_suite_runtime_parent); g_cli_suite_runtime_parent[0] = '\0';