Merge branch 'main' into fix/env-int-parse-strict

This commit is contained in:
Martin Vogel
2026-09-03 21:57:52 +02:00
committed by GitHub
30 changed files with 1634 additions and 307 deletions
+11
View File
@@ -1765,14 +1765,25 @@ static bool cbm_json_mcp_command_path_probe_safe(const char *command) {
#endif
#ifdef CBM_CLI_ENABLE_TEST_API
#ifdef _WIN32
static CBM_TLS int *g_mcp_command_path_probe_counter = NULL;
#endif
bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows) {
return cbm_json_mcp_command_path_probe_safe_for_platform(command, windows);
}
void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter) {
#ifdef _WIN32
g_mcp_command_path_probe_counter = counter;
#else
/* The command-path classifier (cbm_json_mcp_probe_command_path) is compiled
* for Windows only, so on POSIX there is no probe to count. The tests still
* install a counter on every platform and assert it stays at zero; a pointer
* stored here but never read is what Clang 23 rejects under -Werror
* (-Wunused-but-set-global). */
(void)counter;
#endif
}
#endif
+91 -26
View File
@@ -754,23 +754,30 @@ typedef struct {
} tool_annotation_def_t;
/* Tool annotations are deliberately explicit. All tools operate on the local
* repository/index domain, so none cross an open-world trust boundary. */
* repository/index domain, so none cross an open-world trust boundary.
*
* The ten pure query tools resolve their store through resolve_store(), whose
* query-only path is strictly non-mutating: a corrupt database is reported and
* left in place, never quarantined or rebuilt — quarantine/rebuild is reserved
* for write-side opens (index_repository, manage_adr writes). That is what
* makes readOnlyHint=true honest for them and lets plan-mode clients expose
* them (the "read-only analysis tools" surface described in #1100). */
static const tool_annotation_def_t TOOL_ANNOTATIONS[] = {
{"index_repository", false, false, true, false},
{"search_graph", false, true, true, false},
{"query_graph", false, true, true, false},
{"trace_path", false, true, true, false},
{"get_code_snippet", false, true, true, false},
{"search_graph", true, false, true, false},
{"query_graph", true, false, true, false},
{"trace_path", true, false, true, false},
{"get_code_snippet", true, false, true, false},
{"get_file_outline", false, true, true, false},
{"get_graph_schema", false, true, true, false},
{"get_graph_schema", true, false, true, false},
{"compare_graphs", true, false, true, false},
{"get_architecture", false, true, true, false},
{"search_code", false, true, true, false},
{"get_architecture", true, false, true, false},
{"search_code", true, false, true, false},
{"list_projects", true, false, true, false},
{"delete_project", false, true, true, false},
{"index_status", false, true, true, false},
{"check_index_coverage", false, true, true, false},
{"detect_changes", false, true, true, false},
{"index_status", true, false, true, false},
{"check_index_coverage", true, false, true, false},
{"detect_changes", true, false, true, false},
{"manage_adr", false, true, false, false},
{"ingest_traces", false, false, false, false},
};
@@ -1624,6 +1631,12 @@ struct cbm_mcp_server {
bool owns_store; /* true if we opened the store */
char *current_project; /* which project store is open for (heap) */
time_t store_last_used; /* last time resolve_store was called for a named project */
/* Set by a query-only resolve that hit a confirmed-corrupt database and
* left it in place (no quarantine, no rebuild). Error builders read this
* so the tool reply names the corruption instead of a misleading
* "project not found". */
bool readonly_resolve_hit_corrupt;
char readonly_corrupt_project[CBM_SZ_1K];
/* Session + auto-index state */
char session_root[CBM_SZ_1K]; /* detected project root path */
@@ -2217,14 +2230,21 @@ typedef enum {
STORE_RECOVERY_NONE,
STORE_RECOVERY_BUSY,
STORE_RECOVERY_TRY_GUARD_UNAVAILABLE,
/* Query-only resolve: a confirmed-corrupt database was reported and left
* in place. Not an error state of the recovery machinery — the caller is
* expected to answer with the corruption, not retry. */
STORE_RECOVERY_CORRUPT,
} store_recovery_status_t;
static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *project,
bool mutation_already_held, bool nonblocking_recovery,
store_recovery_status_t *recovery_status) {
store_recovery_status_t *recovery_status,
bool allow_autorecovery) {
if (recovery_status) {
*recovery_status = STORE_RECOVERY_NONE;
}
srv->readonly_resolve_hit_corrupt = false;
srv->readonly_corrupt_project[0] = '\0';
if (!project) {
return NULL; /* project is required — no implicit fallback */
}
@@ -2254,6 +2274,38 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr
project_db_path(project, path, sizeof(path));
srv->store = path[0] ? cbm_store_open_path_query(path) : NULL;
if (srv->store) {
/* Query-only resolve: classify a failed integrity check without any
* mutation — no lease, no quarantine. A corrupt database is reported
* and left in place for a write-side open (index_repository) to
* quarantine and rebuild; that is what keeps the ten read-only
* tools' readOnlyHint=true honest. A transient failure (lock, IO, or
* the shallow check's own prepare-error-as-corrupt blind spot) is
* answered as busy and retried by the next resolve. */
if (!allow_autorecovery && !cbm_store_check_integrity(srv->store)) {
cbm_store_close(srv->store);
srv->store = NULL;
srv->store = cbm_store_open_path_query(path);
cbm_integrity_verdict_t verdict = srv->store
? cbm_store_check_integrity_verdict(srv->store)
: CBM_INTEGRITY_TRANSIENT;
if (srv->store) {
cbm_store_close(srv->store);
srv->store = NULL;
}
if (verdict == CBM_INTEGRITY_CORRUPT) {
srv->readonly_resolve_hit_corrupt = true;
snprintf(srv->readonly_corrupt_project, sizeof(srv->readonly_corrupt_project), "%s",
project);
cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, "action",
"left in place for a write-side rebuild");
if (recovery_status) {
*recovery_status = STORE_RECOVERY_CORRUPT;
}
} else if (recovery_status) {
*recovery_status = STORE_RECOVERY_BUSY;
}
return NULL;
}
/* Check DB integrity — back up (never silently delete) a corrupt DB */
if (!cbm_store_check_integrity(srv->store)) {
cbm_store_close(srv->store);
@@ -2358,7 +2410,8 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr
}
static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
return resolve_store_internal(srv, project, false, false, NULL);
/* Query-only callers (every read tool): strictly non-mutating resolve. */
return resolve_store_internal(srv, project, false, false, NULL, false);
}
/* Forward decl — definition lives below alongside list_projects. */
@@ -2489,16 +2542,28 @@ static char *build_no_store_error(const char *project) {
: build_missing_project_error();
}
/* Same contract as build_no_store_error, but when the query-only resolve
* confirmed a corrupt database and left it in place, name that instead of a
* misleading "project not found". */
static char *build_no_store_error_checked(cbm_mcp_server_t *srv, const char *project) {
if (srv->readonly_resolve_hit_corrupt && project &&
strcmp(project, srv->readonly_corrupt_project) == 0) {
return build_project_list_error(
"project store is corrupt (left untouched); run index_repository to rebuild it");
}
return build_no_store_error(project);
}
/* Bail with the right error when no store is available. */
#define REQUIRE_STORE(store, project) \
do { \
if (!(store)) { \
char *_err = build_no_store_error(project); \
char *_res = cbm_mcp_text_result(_err, true); \
free(_err); \
free(project); \
return _res; \
} \
#define REQUIRE_STORE(store, project) \
do { \
if (!(store)) { \
char *_err = build_no_store_error_checked(srv, project); \
char *_res = cbm_mcp_text_result(_err, true); \
free(_err); \
free(project); \
return _res; \
} \
} while (0)
static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) {
@@ -11648,7 +11713,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
char *root_path = get_project_root(srv, project);
if (!root_path) {
char *err = build_no_store_error(project);
char *err = build_no_store_error_checked(srv, project);
char *res = cbm_mcp_text_result(err, true);
free(err);
free(project);
@@ -12418,7 +12483,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
* the UI are visible to each other (#256). */
store_recovery_status_t recovery_status = STORE_RECOVERY_NONE;
cbm_store_t *resolved =
resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status);
resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true);
if (!resolved) {
char *res = NULL;
if (recovery_status == STORE_RECOVERY_BUSY) {
@@ -12427,7 +12492,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
res =
cbm_mcp_text_result("project recovery requires a nonblocking mutation guard", true);
} else {
char *err = build_no_store_error(project);
char *err = build_no_store_error_checked(srv, project);
res = cbm_mcp_text_result(err, true);
free(err);
}
@@ -12479,7 +12544,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
invalidate_cached_store(srv);
resolved = NULL;
store = NULL;
resolved = resolve_store_internal(srv, project, true, false, NULL);
resolved = resolve_store_internal(srv, project, true, false, NULL, true);
}
if (resolved) {
store = open_adr_store_for_write(srv, resolved, &owned_rw);
+9 -1
View File
@@ -627,8 +627,16 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS;
/* Bare-call local-binding suppression. A member call has a receiver the
* guard above can reason about; a bare `run()` has none, so that guard
* cannot see this class at all. Python-only today because the extraction
* flag is set only for Python — this gate MUST match pass_parallel.c's
* exactly, for the same divergence reason noted above. */
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
bool drop_plain_call =
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, res.strategy);
/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
+6 -1
View File
@@ -2492,8 +2492,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT ||
lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS;
/* Bare-call local-binding suppression — see the note in pass_calls.c.
* This gate MUST stay identical to the one there. */
bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON;
bool drop_plain_call =
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy);
cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, res.strategy);
/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
* service signal lives in the callee_name. The registry can mis-resolve
+12
View File
@@ -280,6 +280,18 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
* Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy);
/* Bare-call counterpart of the guard above. True when a resolved BARE call edge
* binds a callee that is shadowed by an enclosing parameter, and the match came
* from a weak short-name strategy — so the edge is fabricated by construction
* (`def f(run): run()` must not bind an unrelated `SatoriLive.run`). Shares the
* member guard's drop-list, so lsp_* / import / same-module matches are kept.
* Deliberately keyed on the SCOPE FACT, not on the callee's spelling. The
* language set lives at the call sites (pass_calls.c / pass_parallel.c) and must
* be identical in both, or the sequential and parallel resolvers diverge.
* Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
const char *strategy);
/* #725: drop a suffix_match CALLS edge when the caller language and the
* target file's language disagree. unique_name (candidates == 1) is #1572
* and is left alone; same_module / import_map / lsp_* are kept. JS/TS/TSX
+47 -10
View File
@@ -448,22 +448,59 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
* per-language decision made at the call sites in pass_calls.c and
* pass_parallel.c, which MUST stay in lockstep — a gate added to only one of
* them diverges the sequential and parallel resolvers. */
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
if (!enabled || !is_method || !strategy || !strategy[0]) {
/* The weak short-name strategies that actually reach the call-resolution
* guards: the registry's suffix_match / unique_name and the parallel
* field_type_hint. "fuzzy" is listed as defensive insurance only —
* cbm_registry_fuzzy_resolve is not wired into the sequential/parallel resolvers
* today, so it never reaches these helpers, but naming it keeps a future wiring
* from silently reintroducing the noise. Everything else — same_module /
* import_map / import_map_suffix / qualified_suffix / callee_suffix /
* service_pattern / lsp_* — is a receiver- or import-aware match and is KEPT.
*
* Shared by BOTH weak-call guards below so the drop-list exists exactly once: a
* list that drifted between the member guard and the local-binding guard would
* make the two disagree about what "weak" means. */
static bool weak_short_name_strategy(const char *strategy) {
if (!strategy || !strategy[0]) {
return false;
}
/* Weak short-name strategies that actually reach the call-resolution guards:
* the registry's suffix_match / unique_name and the parallel field_type_hint.
* "fuzzy" is listed as defensive insurance only — cbm_registry_fuzzy_resolve
* is not wired into the sequential/parallel resolvers today, so it never
* reaches this helper, but naming it keeps a future wiring from silently
* reintroducing the noise. Everything else — same_module / import_map /
* import_map_suffix / qualified_suffix / callee_suffix / service_pattern /
* lsp_* — is a receiver- or import-aware match and is KEPT. */
return strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "unique_name") == 0 ||
strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0;
}
bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy) {
if (!enabled || !is_method) {
return false;
}
return weak_short_name_strategy(strategy);
}
/* Bare-call counterpart of the member guard above. A Python call `foo()` whose
* callee identifier is bound as a parameter of an enclosing scope cannot be the
* module-level `foo`: the parameter shadows it for the whole body. Binding such
* a call to a project Function/Method by a weak short-name strategy fabricates
* the edge by construction (`def _run_with_heavy_slot(run): run()` ->
* SatoriLive.run).
*
* This is deliberately NOT keyed on the callee's spelling. A list of
* "generic-looking" names (get / run / execute) asserts that certain spellings
* are usually noise, which is a claim about corpus fashion rather than about
* what the resolver knew — and it ages invisibly, because nothing fails when the
* distribution shifts, the graph just quietly loses different edges. A parameter
* binding is a fact about THIS file's scope, decidable outright.
*
* `enabled` is the caller's per-language gate, kept out of the helper for the
* same reason as the member guard: the two call sites in pass_calls.c and
* pass_parallel.c MUST enumerate the identical language set, or the sequential
* and parallel resolvers diverge. Pure; unit-tested in test_registry.c. */
bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound,
const char *strategy) {
if (!enabled || !callee_is_locally_bound) {
return false;
}
return weak_short_name_strategy(strategy);
}
static bool js_ts_family(CBMLanguage lang) {
return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX ||
lang == CBM_LANG_ARKTS;
+8 -2
View File
@@ -70,6 +70,10 @@
/* ── Constants ────────────────────────────────────────────────── */
#ifndef CBM_VERSION
#define CBM_VERSION "dev"
#endif
/* Max JSON-RPC request body size (1 MB) — transport enforces the same cap. */
#define MAX_BODY_SIZE CBM_HTTP_MAX_BODY
@@ -147,8 +151,10 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
* audit forbids hardcoded external URLs in graph-ui source (external
* targets must come from an auditable backend response, same pattern as
* the /api/repo-info deep-links). */
cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\",\"upstream_issues_url\":\"%s\"}",
lang_buf, "https://github.com/DeusData/codebase-memory-mcp/issues/new");
cbm_http_replyf(c, 200, g_cors_json,
"{\"lang\":\"%s\",\"version\":\"%s\",\"upstream_issues_url\":\"%s\"}",
lang_buf, CBM_VERSION,
"https://github.com/DeusData/codebase-memory-mcp/issues/new");
}
/* ── Server state ─────────────────────────────────────────────── */