From 5e328814fc99ffe588daae4f0f1e8cf5066e084b Mon Sep 17 00:00:00 2001 From: Jumaga2015 Date: Tue, 1 Sep 2026 21:00:26 +0200 Subject: [PATCH 01/16] fix(store,ui): export paired free functions for FFI callers cbm_store_fetch_call_edges, cbm_leiden/cbm_louvain, and cbm_layout_to_json document caller-owned memory ("caller frees both" / "the caller frees it" / "Caller must free()") but exported no matching free function, unlike the rest of the API (cbm_store_find_nodes -> cbm_store_free_nodes, cbm_layout_compute -> cbm_layout_free, etc.). From C a bare free() works via static linking, but a caller across an FFI boundary (e.g. C# P/Invoke) has no way to reach the DLL's internal free() unless it is exported by name. Adds three paired free functions following the existing pattern: - cbm_store_free_call_edges(src, tgt) - cbm_leiden_free(result) - cbm_layout_free_json(json) Updates the one internal production caller (mcp.c SCC analysis) to use cbm_store_free_call_edges instead of a bare free(), and the existing unit tests for cbm_leiden/cbm_louvain/cbm_layout_to_json to use the new paired frees. Adds fetch_call_edges_basic, fetch_call_edges_truncated, and fetch_call_edges_empty_project to test_store_arch.c, covering cbm_store_fetch_call_edges / cbm_store_free_call_edges, which had no prior test coverage. Fixes #1762 Signed-off-by: Jumaga2015 --- src/mcp/mcp.c | 6 +-- src/store/store.c | 9 +++++ src/store/store.h | 6 +++ src/ui/layout3d.c | 4 ++ src/ui/layout3d.h | 3 ++ tests/test_store_arch.c | 81 +++++++++++++++++++++++++++++++++++++---- tests/test_ui.c | 4 +- 7 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e908211b8..1d154bb28 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -5545,12 +5545,10 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t *scanned_edges = ecount; scc_graph_t g; if (!scc_build(src, tgt, ecount, &g)) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); return CBM_STORE_OK; /* no edges = no cycles, not an error */ } - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); int *comp = malloc((size_t)g.nverts * sizeof(int)); if (!comp) { diff --git a/src/store/store.c b/src/store/store.c index 03e42b0ed..e36c93b1b 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -3142,6 +3142,11 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge return CBM_STORE_OK; } +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { + free(src); + free(tgt); +} + int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count) { bind_id_t b = {source_id}; @@ -8234,6 +8239,10 @@ int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t * return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count); } +void cbm_leiden_free(cbm_louvain_result_t *result) { + free(result); +} + /* ── Architecture: community clusters via Leiden ───────────────── */ enum { diff --git a/src/store/store.h b/src/store/store.h index 165356c31..7bd7e0bbe 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -574,6 +574,9 @@ int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int cou int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated); +/* Free the (source_id, target_id) arrays returned by cbm_store_fetch_call_edges. */ +void cbm_store_free_call_edges(int64_t *src, int64_t *tgt); + /* Find edges by source node. */ int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count); @@ -996,6 +999,9 @@ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *e int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, cbm_louvain_result_t **out, int *out_count); +/* Free the result array returned by cbm_leiden / cbm_louvain. */ +void cbm_leiden_free(cbm_louvain_result_t *result); + /* ── Memory management helpers ──────────────────────────────────── */ /* Free heap-allocated strings in a stack-allocated node (does NOT free the node itself). */ diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 3176da2ff..8a5e32aea 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -867,3 +867,7 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { } return json; } + +void cbm_layout_free_json(char *json) { + free(json); +} diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 20303bf64..758f0be74 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -71,4 +71,7 @@ void cbm_layout_free(cbm_layout_result_t *result); /* Serialize layout result to JSON string. Caller must free(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); +/* Free the JSON string returned by cbm_layout_to_json. */ +void cbm_layout_free_json(char *json); + #endif /* CBM_UI_LAYOUT3D_H */ diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 4e9f59068..aeb8b3d05 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -1189,6 +1189,68 @@ TEST(adr_validate_keys_empty) { PASS(); } +/* ── cbm_store_fetch_call_edges / cbm_store_free_call_edges tests ─ */ + +TEST(fetch_call_edges_basic) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + /* setup_arch_test_store() wires 5 CALLS edges among Function nodes. */ + ASSERT_EQ(count, 5); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_truncated) { + cbm_store_t *s = setup_arch_test_store(); + ASSERT_NOT_NULL(s); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = false; + /* Only 5 CALLS edges exist; capping at 2 must set truncated. */ + ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 2, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_TRUE(truncated); + + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + +TEST(fetch_call_edges_empty_project) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "empty", "/tmp/empty"); + + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "empty", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + ASSERT_FALSE(truncated); + + /* cbm_store_free_call_edges(NULL, NULL) must not crash. */ + cbm_store_free_call_edges(src, tgt); + cbm_store_close(s); + PASS(); +} + /* ── Louvain tests ──────────────────────────────────────────────── */ TEST(louvain_basic) { @@ -1214,7 +1276,7 @@ TEST(louvain_basic) { /* Triangle and pair different */ ASSERT_TRUE(comm[1] != comm[4]); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1223,7 +1285,7 @@ TEST(louvain_empty) { int count = 0; ASSERT_EQ(cbm_louvain(NULL, 0, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 0); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1234,7 +1296,7 @@ TEST(louvain_single_node) { ASSERT_EQ(cbm_louvain(nodes, 1, NULL, 0, &result, &count), CBM_STORE_OK); ASSERT_EQ(count, 1); ASSERT_EQ(result[0].node_id, 42); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1297,7 +1359,7 @@ TEST(louvain_converges) { } ASSERT_TRUE(same_count >= 8); - free(result); + cbm_leiden_free(result); PASS(); } @@ -1431,7 +1493,7 @@ TEST(leiden_multilevel_collapses_noise) { } ASSERT_TRUE(same >= SZ - 1); } - free(result); + cbm_leiden_free(result); PASS(); } @@ -1463,8 +1525,8 @@ TEST(leiden_resolution_controls_granularity) { ASSERT_TRUE(n_hi > n_lo); ASSERT_TRUE(leiden_all_communities_connected(lo, N, edges, ne)); ASSERT_TRUE(leiden_all_communities_connected(hi, N, edges, ne)); - free(lo); - free(hi); + cbm_leiden_free(lo); + cbm_leiden_free(hi); PASS(); } @@ -1727,6 +1789,11 @@ SUITE(store_arch) { RUN_TEST(adr_splice_refuses_unterminated_fence); RUN_TEST(adr_validate_keys_empty); + /* Call-edge fetch / free */ + RUN_TEST(fetch_call_edges_basic); + RUN_TEST(fetch_call_edges_truncated); + RUN_TEST(fetch_call_edges_empty_project); + /* Louvain */ RUN_TEST(louvain_basic); RUN_TEST(louvain_empty); diff --git a/tests/test_ui.c b/tests/test_ui.c index a403dbbbe..c1975a4ca 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -548,7 +548,7 @@ TEST(layout_to_json) { ASSERT(strstr(json, "\"hello\"") != NULL); ASSERT(strstr(json, "\"Function\"") != NULL); - free(json); + cbm_layout_free_json(json); cbm_layout_free(r); cbm_store_close(store); PASS(); @@ -719,7 +719,7 @@ TEST(layout_dead_code_classification) { ASSERT_NOT_NULL(json); ASSERT(strstr(json, "\"status\":\"dead\"") != NULL); ASSERT(strstr(json, "\"in_calls\":2") != NULL); - free(json); + cbm_layout_free_json(json); cbm_layout_free(r); cbm_store_close(store); From 1e92269d7562d89a816271c4d99904ee53a94606 Mon Sep 17 00:00:00 2001 From: Mark Murawski Date: Wed, 2 Sep 2026 13:38:05 -0400 Subject: [PATCH 02/16] feat(workspace): add PATH_ALLOW_BROAD override for too-shallow roots CBM_WS_DENY_TOO_SHALLOW ("path is too broad to index as one root") has no override today: cbm_workspace_verdict_is_overridable() only lifts CBM_WS_DENY_SENSITIVE, and `allow-root --list` already documents shallow/absolute refusals as "always-refused" rather than configurable. That default is right -- a bare top-level tree like "/etc" or "/home" is almost always a mistake -- but it leaves no way to say "I mean this one specific broad root, on purpose." That's a real case: a person who deliberately wants one combined project spanning several sibling repos under a shared parent has no path forward today short of physically restructuring their checkout. PATH_ALLOW_BROAD names one exact canonical path that lifts a CBM_WS_DENY_TOO_SHALLOW verdict, and only that verdict: - exact match only, not a prefix -- naming one broad root must not quietly approve every root below it too, the same "/Users"-style breadth this depth rule exists to catch in the first place - process environment, not a recorded grant: it never appears in cbm_workspace_grant_list, and cbm_workspace_verdict_is_overridable() is untouched, so CBM_WS_DENY_ABSOLUTE and CBM_WS_DENY_SENSITIVE stay exactly as unliftable as before - read once per call site with getenv(), mirroring how CBM_ALLOWED_ROOT is already threaded into cbm_workspace_root_allowed() from each of its four callers, rather than read inside cbm_workspace_classify_root() itself -- that function is documented as a pure function of its arguments precisely so it stays host-independent and directly testable Also updates the refusal message to name the fix, the same way the sensitive-root refusal already names `allow-root --approve-sensitive`. Adds ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only alongside the existing too-shallow coverage, checking the exact-match requirement, the untouched sensitive/absolute paths, and that nothing is written to the grant store. Signed-off-by: Mark Murawski --- src/daemon/application.c | 3 ++- src/foundation/workspace.c | 28 +++++++++++++++++--- src/foundation/workspace.h | 11 +++++--- src/mcp/mcp.c | 6 +++-- src/ui/http_server.c | 3 ++- tests/test_workspace.c | 52 +++++++++++++++++++++++++++++++++----- 6 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 12543808e..ecb5095c7 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -437,7 +437,8 @@ static bool application_session_workspace_allowed(const cbm_daemon_application_s bool allowed = root && root[0] && cbm_workspace_root_allowed(root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - cbm_mcp_server_allowed_root(session->mcp), boundary_error, + cbm_mcp_server_allowed_root(session->mcp), + getenv("PATH_ALLOW_BROAD"), boundary_error, sizeof(boundary_error)); if (!allowed) { cbm_log_warn("daemon.workspace.skipped", "operation", operation, "detail", diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index cb254f3d9..0daab3806 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -522,8 +522,8 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, - const char *cache_dir, const char *configured_root, char *err, - size_t err_sz) { + const char *cache_dir, const char *configured_root, + const char *allow_broad_root, char *err, size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -579,17 +579,37 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return true; } /* An explicit human approval recorded for exactly this path is the only thing - * that lifts a sensitive refusal. Absolute and shallow refusals cannot be - * lifted at all. */ + * that lifts a sensitive refusal. Absolute refusals cannot be lifted at all. */ if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) { return true; } + /* PATH_ALLOW_BROAD is a separate, narrower lever from the grant store above: + * an operator has named this exact root as one they have deliberately chosen + * to index whole, accepting that it may span multiple unrelated projects -- + * the tradeoff CBM_WS_DENY_TOO_SHALLOW normally refuses on their behalf. It + * must match canonical_path exactly, the same way an exact sensitive grant + * does above: a prefix match would let one broad allowance quietly cover + * every root below it too, which is the "/Users"-style breadth this rule + * exists to catch. It is environment, not a recorded grant, so it is not + * listed by cbm_workspace_grant_list and cannot lift CBM_WS_DENY_ABSOLUTE or + * CBM_WS_DENY_SENSITIVE. */ + if (verdict == CBM_WS_DENY_TOO_SHALLOW && allow_broad_root && allow_broad_root[0] && + ws_paths_equal(canonical_path, allow_broad_root)) { + return true; + } if (err) { if (cbm_workspace_verdict_is_overridable(verdict)) { snprintf(err, err_sz, "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root " "--approve-sensitive %s", canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); + } else if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + /* No allow-root command exists for this one -- the grant store is a + * human-approved list of real project roots, and this path is being + * refused for being the opposite of that. PATH_ALLOW_BROAD is the + * deliberate, narrower escape hatch: set it to this exact path. */ + snprintf(err, err_sz, "%s: %s. To index it anyway, set PATH_ALLOW_BROAD=%s", + canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); } else { snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict)); } diff --git a/src/foundation/workspace.h b/src/foundation/workspace.h index 276079155..4884dbdd0 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -93,10 +93,15 @@ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz); /* The whole decision, used by every entry point that accepts a repo path. * canonical_path must already be canonicalized. configured_root is the legacy * CBM_ALLOWED_ROOT / session policy, treated as an additional grant, or NULL. - * On refusal, err receives a message that names the command which would fix it. */ + * allow_broad_root is PATH_ALLOW_BROAD (or NULL): an operator-set exact match + * for canonical_path that lifts a CBM_WS_DENY_TOO_SHALLOW verdict only. It is + * process environment, not a recorded grant, so it does not appear in + * cbm_workspace_grant_list and does not widen CBM_WS_DENY_ABSOLUTE or + * CBM_WS_DENY_SENSITIVE. On refusal, err receives a message that names the + * command which would fix it. */ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, - const char *cache_dir, const char *configured_root, char *err, - size_t err_sz); + const char *cache_dir, const char *configured_root, + const char *allow_broad_root, char *err, size_t err_sz); /* The home and cache directories the policy should be evaluated against. Shared * so two callers cannot derive them differently and reach different verdicts for diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 73534404a..372cd521f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -8747,7 +8747,8 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { char boundary_err[CBM_SZ_1K]; if (repo_path && repo_path[0] && !cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - allowed_root, boundary_err, sizeof(boundary_err))) { + allowed_root, getenv("PATH_ALLOW_BROAD"), boundary_err, + sizeof(boundary_err))) { free(mode_str); free(name_override); free(repo_path); @@ -12851,7 +12852,8 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT"); char boundary_err[CBM_SZ_1K]; if (!cbm_workspace_root_allowed(srv->session_root, cbm_workspace_home_dir(), - cbm_workspace_cache_dir(), allowed_root, boundary_err, + cbm_workspace_cache_dir(), allowed_root, + getenv("PATH_ALLOW_BROAD"), boundary_err, sizeof(boundary_err))) { cbm_log_warn("autoindex.skip", "reason", "workspace_boundary", "detail", boundary_err); return; diff --git a/src/ui/http_server.c b/src/ui/http_server.c index b187438a5..c95c9d79e 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1173,7 +1173,8 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, } if (!cbm_workspace_root_allowed(canonical_root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), getenv("CBM_ALLOWED_ROOT"), - boundary_err, sizeof(boundary_err))) { + getenv("PATH_ALLOW_BROAD"), boundary_err, + sizeof(boundary_err))) { yyjson_doc_free(doc); char escaped[1024]; cbm_json_escape(escaped, (int)sizeof(escaped), boundary_err); diff --git a/tests/test_workspace.c b/tests/test_workspace.c index faf936e1b..e87958ea9 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -70,6 +70,45 @@ TEST(ws_posix_top_level_trees_are_too_shallow) { PASS(); } +/* PATH_ALLOW_BROAD is deliberately not part of the grant store's overridability: + * cbm_workspace_classify_root and cbm_workspace_verdict_is_overridable must stay + * exactly as strict as the test above already checked. Only the top-level + * decision, cbm_workspace_root_allowed, knows about it, and only as an exact + * match. */ +TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { + char cache[256]; + char *created = th_mktempdir("cbm_ws_allow_broad"); + ASSERT_NOT_NULL(created); + snprintf(cache, sizeof(cache), "%s", created); + + char err[1024]; + ASSERT_FALSE(cbm_workspace_root_allowed("/srv", "/home/dev", cache, NULL, NULL, err, + sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "PATH_ALLOW_BROAD")); + + ASSERT_TRUE( + cbm_workspace_root_allowed("/srv", "/home/dev", cache, NULL, "/srv", err, sizeof(err))); + + /* A grant for the root does not extend to a sibling: PATH_ALLOW_BROAD names + * one path, not a prefix. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/opt", "/home/dev", cache, NULL, "/srv", err, + sizeof(err))); + + /* Sensitive and absolute refusals are a different verdict and stay refused + * even when PATH_ALLOW_BROAD happens to name that exact path. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/home/dev", "/home/dev", cache, NULL, "/home/dev", + err, sizeof(err))); + ASSERT_FALSE( + cbm_workspace_root_allowed("/", "/home/dev", cache, NULL, "/", err, sizeof(err))); + + /* Environment, not a grant: it never shows up in the recorded list. */ + char listed[64]; + ASSERT_FALSE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + + th_cleanup(cache); + PASS(); +} + /* Legitimately shallow project roots must survive: these are the false positives * a blanket depth rule would cause, which is why Windows counts drive-relative. */ TEST(ws_legitimate_shallow_roots_are_allowed) { @@ -170,10 +209,10 @@ TEST(ws_sensitive_root_explicit_approval_is_preserved) { snprintf(cache, sizeof(cache), "%s", created); char err[1024]; - ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); th_cleanup(root); th_cleanup(cache); @@ -193,10 +232,10 @@ TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant) { char err[1024]; ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, err, sizeof(err))); - ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); /* A repeated explicit approval must recognize the marked exact grant rather * than append another exception. */ ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); @@ -228,11 +267,11 @@ TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor) { ASSERT_EQ(cbm_mkdir(sensitive), 0); char err[1024]; ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, err, sizeof(err))); - ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, NULL, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); ASSERT_TRUE( - cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); + cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, NULL, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); char listed[4096]; @@ -385,6 +424,7 @@ SUITE(workspace) { RUN_TEST(ws_volume_roots_are_absolutely_denied); RUN_TEST(ws_non_absolute_paths_are_denied); RUN_TEST(ws_posix_top_level_trees_are_too_shallow); + RUN_TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only); RUN_TEST(ws_legitimate_shallow_roots_are_allowed); RUN_TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine); RUN_TEST(ws_credential_directories_are_sensitive_at_any_depth); From 56de845327dce03cdd1269b8aa73c6a1ecdb5cfa Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sun, 20 Sep 2026 16:35:23 +0800 Subject: [PATCH 03/16] fix(cli): support non-ASCII Windows agent paths Signed-off-by: lorenzozanee --- src/cli/cli.c | 9 +++------ tests/test_cli.c | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 51fbafc02..7cd59e0bf 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -19,6 +19,7 @@ #include "daemon/runtime.h" #include "daemon/version_cohort.h" #include "foundation/compat.h" +#include "foundation/compat_fs.h" #include "foundation/platform.h" #include "foundation/constants.h" #include "foundation/log.h" @@ -2600,12 +2601,8 @@ int cbm_remove_zed_mcp_owned(const char *binary_path, const char *config_path) { /* ── Agent detection ──────────────────────────────────────────── */ static bool dir_exists(const char *path) { - struct stat st; -#ifndef _WIN32 - return lstat(path, &st) == 0 && S_ISDIR(st.st_mode); -#else - return stat(path, &st) == 0 && S_ISDIR(st.st_mode); -#endif + cbm_path_info_t info = {0}; + return cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_directory; } /* Resolve the Claude Code config dir. diff --git a/tests/test_cli.c b/tests/test_cli.c index 06ebb3841..b3c3c2f27 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -13074,6 +13074,24 @@ TEST(cli_detect_agents_finds_zed_in_roaming_appdata) { } #endif +#ifdef _WIN32 +TEST(cli_detect_agents_handles_non_ascii_windows_profile) { + char home[1024]; + snprintf(home, sizeof(home), "%s/cbm-用户-profile", cbm_tmpdir()); + test_rmdir_r(home); + char claude_dir[1024]; + snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", home); + if (!test_mkdirp(claude_dir)) + FAIL("could not create non-ASCII Windows profile path"); + + cbm_detected_agents_t agents = cbm_detect_agents(home); + test_rmdir_r(home); + if (!agents.claude_code) + FAIL("Claude detection must support non-ASCII Windows profile paths"); + PASS(); +} +#endif + TEST(cli_detect_agents_finds_antigravity) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-detect-XXXXXX"); @@ -16258,6 +16276,7 @@ SUITE(cli) { #endif #ifdef _WIN32 RUN_TEST(cli_detect_agents_finds_zed_in_roaming_appdata); + RUN_TEST(cli_detect_agents_handles_non_ascii_windows_profile); #endif RUN_TEST(cli_detect_agents_finds_antigravity); RUN_TEST(cli_detect_agents_finds_kilocode); From f4c897110cb1f02edee37db7c8849e63894ed8a4 Mon Sep 17 00:00:00 2001 From: MopicMP Date: Wed, 23 Sep 2026 22:44:16 +0200 Subject: [PATCH 04/16] fix(java): resolve record accessor calls, and never bind a call to data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record's accessors are implicit, so `record Point(double x, double y, double z)` declares no `x()` and extraction emitted no node for one. The call `p.x()` had nothing to resolve to, fell through to the short-name registry, and bound to the first `x` in the project — typically an unrelated class's private field. Measured on two indexed Java trees, that was 278 of 2444 and 2649 of 29509 CALLS edges. Extraction now emits one Method definition per record component, skipping any accessor the record declares itself (JLS 8.10.3), so the accessor has a node and the call resolves to it. And a Java call may no longer bind to a Variable or a Field. A Java call expression never names data, so such an edge is a spelling collision, not a call. The guard follows cbm_go_suppress_bare_field_ref — a pure, label-keyed predicate — and is called from both pass_calls.c and pass_parallel.c so the sequential and parallel resolvers stay identical. Java-gated: Kotlin properties hold function values and C has function pointers, so both legitimately call through data. Signed-off-by: MopicMP Co-authored-by: Claude Opus 5 --- internal/cbm/extract_defs.c | 80 ++++++++++++++++++++++++++ src/pipeline/pass_calls.c | 6 ++ src/pipeline/pass_parallel.c | 6 ++ src/pipeline/pipeline.h | 7 +++ src/pipeline/registry.c | 24 ++++++++ tests/test_call_reference_contract.c | 85 ++++++++++++++++++++++++++++ tests/test_registry.c | 22 +++++++ 7 files changed, 230 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 93da084be..fd051c482 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -204,6 +204,7 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch const CBMLangSpec *spec); static TSNode find_class_body(TSNode class_node, CBMLanguage lang); static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *class_qn); +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn); static void extract_elixir_call(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec); // --- Helpers --- @@ -4642,6 +4643,10 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec extract_enum_members(ctx, node, class_qn); } + if (ctx->language == CBM_LANG_JAVA && strcmp(kind, "record_declaration") == 0) { + extract_record_accessors(ctx, node, class_qn); + } + // Extract methods inside the class extract_class_methods(ctx, node, class_qn, spec); @@ -5529,6 +5534,81 @@ static void extract_enum_members(CBMExtractCtx *ctx, TSNode node, const char *cl } } +/* Whether the record body declares this accessor itself (JLS §8.10.3 lets a + * record override any of them), so the implicit one is not emitted twice. */ +static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char *comp_name) { + TSNode body = find_class_body(node, ctx->language); + if (ts_node_is_null(body)) { + return false; + } + uint32_t mc = ts_node_named_child_count(body); + for (uint32_t mi = 0; mi < mc; mi++) { + TSNode member = ts_node_named_child(body, mi); + if (strcmp(ts_node_type(member), "method_declaration") != 0) { + continue; + } + TSNode mname = ts_node_child_by_field_name(member, TS_FIELD("name")); + if (ts_node_is_null(mname)) { + continue; + } + char *name = cbm_node_text(ctx->arena, mname, ctx->source); + if (!name || strcmp(name, comp_name) != 0) { + continue; + } + /* Same name, no parameters — that is the accessor. */ + TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); + if (ts_node_is_null(params) || ts_node_named_child_count(params) == 0) { + return true; + } + } + return false; +} + +/* Java records: the compiler writes one accessor per component, so `p.x()` + * calls a method that appears nowhere in the source. Without a definition for + * it the call has nothing to resolve to and falls through to the project-wide + * name registry, which binds it to the first `x` in the tree — typically an + * unrelated class's private field. Emitting the accessors makes the record's + * own API visible, exactly as the language defines it (JLS §8.10.3). */ +static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char *class_qn) { + CBMArena *a = ctx->arena; + TSNode params = ts_node_child_by_field_name(node, TS_FIELD("parameters")); + if (ts_node_is_null(params)) { + return; + } + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + TSNode comp = ts_node_named_child(params, pi); + if (strcmp(ts_node_type(comp), "formal_parameter") != 0) { + continue; + } + TSNode cname = ts_node_child_by_field_name(comp, TS_FIELD("name")); + if (ts_node_is_null(cname)) { + continue; + } + char *comp_name = cbm_node_text(a, cname, ctx->source); + if (!comp_name || !comp_name[0] || record_declares_accessor(ctx, node, comp_name)) { + continue; + } + CBMDefinition adef; + memset(&adef, 0, sizeof(adef)); + adef.name = comp_name; + adef.qualified_name = cbm_arena_sprintf(a, "%s.%s", class_qn, comp_name); + adef.label = "Method"; + adef.file_path = ctx->rel_path; + adef.start_line = ts_node_start_point(comp).row + TS_LINE_OFFSET; + adef.end_line = ts_node_end_point(comp).row + TS_LINE_OFFSET; + adef.lines = 1; + adef.parent_class = class_qn; + adef.is_exported = true; + TSNode ctype = ts_node_child_by_field_name(comp, TS_FIELD("type")); + if (!ts_node_is_null(ctype)) { + adef.return_type = cbm_node_text(a, ctype, ctx->source); + } + cbm_defs_push(&ctx->result->defs, a, adef); + } +} + /* Resolve the identifier node from a destructure pattern child. * pair_pattern → value field; shorthand/identifier → itself; others → first named child. */ static TSNode destructure_ident(TSNode pat_child) { diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954c..c702f7efd 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -682,6 +682,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } + /* A Java call never targets data — see cbm_java_suppress_call_to_data_member. + * The language gate lives here, as with the guards above, and MUST match + * pass_parallel.c exactly or the two resolvers diverge. */ + if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + return 0; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f..380679c13 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3028,6 +3028,12 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * CALLS edge across a language boundary. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { + /* Same guard as pass_calls.c — a Java call never targets a + * Variable or a Field. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0..416c5b25a 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -344,6 +344,13 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target * unit-tested in test_registry.c. */ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const char *target_label); +/* A Java call expression never names data: `p.x()` is a method invocation + * whatever `x` spells elsewhere in the project. Drops a CALLS bind whose + * target is a Variable or Field. Java only — Kotlin properties and C function + * pointers are callable names that are not methods. Pure; unit-tested in + * test_registry.c. */ +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index c99c72d8d..2a9fb19fb 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -811,6 +811,30 @@ bool cbm_go_suppress_bare_field_ref(bool is_go, bool is_member_access, const cha return strcmp(target_label, "Field") == 0; } +bool cbm_java_suppress_call_to_data_member(bool is_java, const char *target_label) { + /* A Java call expression never names data. `p.x()` is a method invocation + * whatever `x` spells elsewhere in the tree, so a CALLS edge into a + * Variable or a Field is a spelling collision and not a call. + * + * The bind is reached when the type-aware resolver finds no method to + * return: a record's accessors are implicit, so `record Point(double x, + * double y, double z)` declares no `x()` for java_lookup_method to find, + * and every `p.x()` falls through to the short-name registry — which + * matches the first `x` anywhere in the project, typically an unrelated + * class's private field. Measured on a 600-file Java tree (2026-09-22): + * 278 of 2444 CALLS edges landed on fields that way, and the callers of + * one four-field class absorbed every record read in the project. + * + * Java-gated, like the Go guard above, because the veto is only sound + * where no callable name can be anything but a method: Kotlin properties + * hold function values (`val f: () -> Unit; f()`) and C has function + * pointers, so both legitimately call through data. */ + if (!is_java || !target_label) { + return false; + } + return strcmp(target_label, "Variable") == 0 || strcmp(target_label, "Field") == 0; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 3a7b3acd1..d7aa27939 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -35,6 +35,40 @@ static int crc_edge_count(cbm_store_t *store, const char *project, const char *t return matches; } +/* Edges of `type` out of `source_name` whose target's qualified name ends with + * `target_qn_suffix`. Two methods in one project can share a short name, and + * then only the qualified name tells them apart. */ +static int crc_edge_count_to_qn(cbm_store_t *store, const char *project, const char *type, + const char *source_name, const char *target_qn_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(store, project, type, &edges, &edge_count) != CBM_STORE_OK) { + return -1; + } + int matches = 0; + size_t suffix_len = strlen(target_qn_suffix); + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + cbm_node_t target = {0}; + bool source_ok = + cbm_store_find_node_by_id(store, edges[i].source_id, &source) == CBM_STORE_OK; + bool target_ok = + cbm_store_find_node_by_id(store, edges[i].target_id, &target) == CBM_STORE_OK; + if (source_ok && target_ok && source.name && target.qualified_name && + strcmp(source.name, source_name) == 0) { + size_t qn_len = strlen(target.qualified_name); + if (qn_len >= suffix_len && + strcmp(target.qualified_name + qn_len - suffix_len, target_qn_suffix) == 0) { + matches++; + } + } + cbm_node_free_fields(&source); + cbm_node_free_fields(&target); + } + cbm_store_free_edges(edges, edge_count); + return matches; +} + static int crc_global_handler_edge_count(cbm_store_t *store, const char *project, const char *caller) { int references = @@ -423,6 +457,56 @@ TEST(call_reference_go_ambiguous_promoted_method_stays_usage) { PASS(); } +TEST(call_java_record_accessor_binds_the_record) { + /* A record's accessors are implicit, so `record Point(double x, ...)` + * declares no `x()` in its source. Extraction emits them, and `other.x()` + * binds to the record's own accessor. + * + * Two things must not happen instead. The call must not reach the + * project-wide short-name registry and land on the first `x` in the tree — + * here an unrelated class's private field. And the guard that refuses such + * data binds must not swallow a real method that merely shares the + * spelling: the builder's `x(int)`, declared beside its own `x` field, + * keeps its edge. */ + static const RFile files[] = { + {"Point.java", "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double first(Point other) {\n" + " return Maths.abs(other.x()) + new Builder().x(5).made();\n" + " }\n" + "}\n"}, + {"Spring.java", "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"}, + {"Builder.java", "package com.example;\n" + "public final class Builder {\n" + " private int x;\n" + " public Builder x(int v) { this.x = v; return this; }\n" + " public int made() { return x; }\n" + "}\n"}, + {"Maths.java", "package com.example;\n" + "public final class Maths {\n" + " public static double abs(double v) { return v < 0 ? -v : v; }\n" + "}\n"}}; + RProj project; + cbm_store_t *store = rh_index_files(&project, files, 4); + ASSERT_NOT_NULL(store); + int fabricated_field = crc_edge_count(store, project.project, "CALLS", "first", "Field", "x"); + int fabricated_var = crc_edge_count(store, project.project, "CALLS", "first", "Variable", "x"); + int genuine = crc_edge_count(store, project.project, "CALLS", "first", "Method", "abs"); + int accessor = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Point.x"); + int builder = crc_edge_count_to_qn(store, project.project, "CALLS", "first", ".Builder.x"); + rh_cleanup(&project, store); + ASSERT_EQ(fabricated_field, 0); + ASSERT_EQ(fabricated_var, 0); + ASSERT_EQ(genuine, 1); + ASSERT_EQ(accessor, 1); + ASSERT_EQ(builder, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -439,4 +523,5 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_python_later_decorated_method_rebinding_stays_usage); RUN_TEST(call_reference_go_bound_method_argument_is_exact); RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); + RUN_TEST(call_java_record_accessor_binds_the_record); } diff --git a/tests/test_registry.c b/tests/test_registry.c index 5f751a899..126400da6 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -987,6 +987,27 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(java_call_never_binds_data_member) { + /* A Java call expression never names data, so a CALLS bind that landed on + * a Variable or a Field is a spelling collision. The shape that produces + * it: a record's accessors are implicit, `p.x()` finds no declared `x()`, + * and the short-name registry hands back the first `x` in the tree — an + * unrelated class's private field. */ + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Field")); + ASSERT_TRUE(cbm_java_suppress_call_to_data_member(true, "Variable")); + /* Everything callable is kept, including the constructor's Class target. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Method")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Function")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, "Class")); + /* Other languages have callable data: a Kotlin property or a C function + * pointer is a name that is not a method and is called all the same. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Field")); + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(false, "Variable")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_java_suppress_call_to_data_member(true, NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -1244,6 +1265,7 @@ SUITE(registry) { RUN_TEST(registry_tie_break_is_independent_of_registration_order); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(java_call_never_binds_data_member); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); From e36930742a63efd2bbca491996ba8e37bfa5822c Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 28 Sep 2026 08:59:32 -0500 Subject: [PATCH 05/16] perf(py-lsp): O(1) type membership in the shared Python cross-registry build The shared tier-2 registry registered Python defs one at a time without a mid-build finalize, so every Method's receiver probe scanned all registered types linearly: O(methods x types), single-threaded in lsp_cross_prepare. Keep the per-def registration order and back only the existence probe with a cbm_ht set of type QNs, as the Go and Rust builders do; the registry is identical by construction. On the Python 3.14 stdlib + site-packages the builder drops from 67-75 s to 0.20-0.25 s, with a byte-identical graph. Signed-off-by: Nelson Spence --- internal/cbm/lsp/py_lsp.c | 52 ++++++++++++++++++++--- tests/test_py_lsp.c | 86 +++++++++++++++++++++++++++++++++++++++ tests/test_py_lsp_scale.c | 66 ++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 5 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 8a0396115..961e35f15 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -13,6 +13,7 @@ #include "py_lsp.h" #include "../cbm.h" #include "../helpers.h" +#include "../../../src/foundation/hash_table.h" #include "tree_sitter/api.h" #include #include @@ -4973,10 +4974,36 @@ static const char **py_split_pipe(CBMArena *arena, const char *text) { return out; } +/* Qualified names of reg->types[0..noted), for a caller that registers into a + * registry it does not finalize until the end (cbm_py_build_cross_registry): + * that registry's own lookup scans every type. qns == NULL probes the + * registry instead. */ +typedef struct { + CBMHashTable *qns; + int noted; +} PyKnownTypes; + +/* Note the types added since the last call. Run after every add_type, so an + * add that failed silently cannot desync set and registry. If the set cannot + * grow it is dropped and probes fall back to the registry. */ +static void py_note_known_types(PyKnownTypes *known, const CBMTypeRegistry *reg) { + for (; known && known->qns && known->noted < reg->type_count; known->noted++) { + const char *qn = reg->types[known->noted].qualified_name; + if (!qn || cbm_ht_has(known->qns, qn)) + continue; + cbm_ht_set(known->qns, qn, (void *)(uintptr_t)(known->noted + 1)); + if (!cbm_ht_has(known->qns, qn)) { + cbm_ht_free(known->qns); + known->qns = NULL; + } + } +} + /* Build a registry from CBMLSPDef[] supplied by the caller — covers both - * the source file's own defs and cross-file referenced defs. */ + * the source file's own defs and cross-file referenced defs. known is NULL + * except for the shared Tier-2 build. */ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRegistry *reg, - CBMLSPDef *defs, int def_count) { + CBMLSPDef *defs, int def_count, PyKnownTypes *known) { /* Pass 1: types only — the method pass probes the registry per Method def * (receiver auto-registration), which is a LINEAR scan pre-finalize: * O(methods x types) per file (same quadratic as php_register_lsp_defs; @@ -4999,6 +5026,7 @@ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe rt.method_names = py_split_pipe(arena, d->method_names_str); } cbm_registry_add_type(reg, rt); + py_note_known_types(known, reg); } } /* idx_arena == NULL skips the mid-build finalize: the tier-2 builder @@ -5046,13 +5074,17 @@ static void py_register_lsp_defs(CBMArena *arena, CBMArena *idx_arena, CBMTypeRe if (strcmp(d->label, "Method") == 0 && d->receiver_type && d->receiver_type[0]) { rf.receiver_type = d->receiver_type; /* borrowed */ - if (!cbm_registry_lookup_type(reg, rf.receiver_type)) { + bool recv_known = known && known->qns + ? cbm_ht_has(known->qns, rf.receiver_type) + : cbm_registry_lookup_type(reg, rf.receiver_type) != NULL; + if (!recv_known) { CBMRegisteredType auto_t; memset(&auto_t, 0, sizeof(auto_t)); auto_t.qualified_name = rf.receiver_type; const char *dot = strrchr(d->receiver_type, '.'); auto_t.short_name = dot ? dot + 1 : rf.receiver_type; /* borrowed substring */ cbm_registry_add_type(reg, auto_t); + py_note_known_types(known, reg); } } cbm_registry_add_func(reg, rf); @@ -5092,7 +5124,7 @@ void cbm_run_py_lsp_cross(CBMArena *arena, const char *source, int source_len, /* Index allocations go to a per-call scratch arena (see php_lsp_cross). */ CBMArena idx_arena; cbm_arena_init(&idx_arena); - py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count); + py_register_lsp_defs(arena, &idx_arena, ®, defs, def_count, NULL); py_mark_ambiguous_callable_bindings(®); /* Finalize registry — O(1) lookups. See go_lsp.c "3c. Finalize" @@ -5126,14 +5158,24 @@ CBMTypeRegistry *cbm_py_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i cbm_registry_init(reg, arena); cbm_python_stdlib_register(reg, arena); + /* The registry is not finalized until the end, so probing it for each + * Method's receiver scanned every type: O(methods x types), single-threaded + * (~54 s of lsp_cross_prepare on a large Python tree). Probe a set of + * the registered type QNs instead; registration order is unchanged. Sized + * for the stdlib seed, not def_count (the mixed all-language count). NULL + * on allocation failure: the registry probe runs as before. */ + PyKnownTypes known = {cbm_ht_create((uint32_t)reg->type_count), 0}; + py_note_known_types(&known, reg); + /* Filter to Python defs only — defs[] is mixed-language all_defs. */ for (int i = 0; i < def_count; i++) { CBMLSPDef *d = &defs[i]; if (d->lang != CBM_LANG_PYTHON) continue; /* Reuse the existing register fn on a single-def slice (n=1 inline). */ - py_register_lsp_defs(arena, NULL, reg, d, 1); + py_register_lsp_defs(arena, NULL, reg, d, 1, &known); } + cbm_ht_free(known.qns); py_mark_ambiguous_callable_bindings(reg); cbm_registry_finalize(reg); diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 3aaadee51..798c80a7b 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -637,6 +637,91 @@ TEST(pylsp_fused_self_attr_chain_via_overlay) { PASS(); } +/* Characterization of the shared Tier-2 build order. Defs register one at a + * time in defs[] order, so a Method seen before its Class auto-registers a + * placeholder receiver type that stays ahead of the Class entry, and a Method + * whose receiver is already known adds no type. Any change to how the build + * probes for known receivers must keep this exact sequence. */ +TEST(pylsp_cross_registry_interleaved_order) { + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry *base = cbm_py_build_cross_registry(&arena, NULL, 0); + ASSERT_NOT_NULL(base); + int tb = base->type_count, fb = base->func_count; + + CBMLSPDef defs[] = { + {.qualified_name = "t.m.A.m", + .short_name = "m", + .label = "Method", + .receiver_type = "t.m.A", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.A", + .short_name = "A", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B", + .short_name = "B", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.B.f", + .short_name = "f", + .label = "Method", + .receiver_type = "t.m.B", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + {.qualified_name = "t.m.C.g", + .short_name = "g", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Receiver auto-registered by C.g just above: no second placeholder. */ + {.qualified_name = "t.m.C.h", + .short_name = "h", + .label = "Method", + .receiver_type = "t.m.C", + .def_module_qn = "t.m", + .lang = CBM_LANG_PYTHON}, + /* Non-Python defs are skipped by the builder. */ + {.qualified_name = "t.m.D", + .short_name = "D", + .label = "Class", + .def_module_qn = "t.m", + .lang = CBM_LANG_GO}, + }; + CBMTypeRegistry *reg = + cbm_py_build_cross_registry(&arena, defs, (int)(sizeof(defs) / sizeof(defs[0]))); + ASSERT_NOT_NULL(reg); + + /* Types: placeholder A (from A.m), Class A, Class B, placeholder C. */ + ASSERT_EQ(reg->type_count, tb + 4); + ASSERT_TRUE(reg->types[tb].qualified_name == defs[0].receiver_type); + ASSERT_STR_EQ(reg->types[tb].short_name, "A"); + ASSERT_TRUE(reg->types[tb + 1].qualified_name == defs[1].qualified_name); + ASSERT_TRUE(reg->types[tb + 2].qualified_name == defs[2].qualified_name); + ASSERT_TRUE(reg->types[tb + 3].qualified_name == defs[4].receiver_type); + ASSERT_STR_EQ(reg->types[tb + 3].short_name, "C"); + + /* Funcs keep defs[] order. */ + ASSERT_EQ(reg->func_count, fb + 4); + ASSERT_STR_EQ(reg->funcs[fb].qualified_name, "t.m.A.m"); + ASSERT_STR_EQ(reg->funcs[fb + 1].qualified_name, "t.m.B.f"); + ASSERT_STR_EQ(reg->funcs[fb + 2].qualified_name, "t.m.C.g"); + ASSERT_STR_EQ(reg->funcs[fb + 2].receiver_type, "t.m.C"); + ASSERT_STR_EQ(reg->funcs[fb + 3].qualified_name, "t.m.C.h"); + + const CBMRegisteredType *a = cbm_registry_lookup_type(reg, "t.m.A"); + ASSERT_NOT_NULL(a); + ASSERT_STR_EQ(a->qualified_name, "t.m.A"); + ASSERT_NULL(cbm_registry_lookup_type(reg, "t.m.D")); + + cbm_arena_destroy(&arena); + PASS(); +} + /* Issue #228: a class/static method invoked directly on a CROSS-FILE imported * class name — ActionRecordX.build_from_text(...) — produced no CALLS edge, so * the method showed in/out degree 0 and was flagged as dead code. Distinct from @@ -2194,6 +2279,7 @@ SUITE(py_lsp) { /* Phase 9 — cross-file + batch */ RUN_TEST(pylsp_crossfile_method_dispatch); RUN_TEST(pylsp_fused_self_attr_chain_via_overlay); + RUN_TEST(pylsp_cross_registry_interleaved_order); RUN_TEST(pylsp_crossfile_classmethod_on_class_issue228); RUN_TEST(pylsp_crossfile_inheritance); RUN_TEST(pylsp_batch_two_files); diff --git a/tests/test_py_lsp_scale.c b/tests/test_py_lsp_scale.c index f20e76275..eee1575fd 100644 --- a/tests/test_py_lsp_scale.c +++ b/tests/test_py_lsp_scale.c @@ -111,6 +111,72 @@ TEST(pylsp_scale_linear_growth) { PASS(); } +/* Time cbm_py_build_cross_registry over n classes, each followed by two + * methods (extraction order): every method probes the registry for its + * receiver while the registry is still being built. */ +static double measure_cross_registry(int n_classes, int *out_types) { + CBMArena arena; + cbm_arena_init(&arena); + int n_defs = n_classes * 3; + CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); + if (!defs) { + cbm_arena_destroy(&arena); + return -1.0; + } + for (int i = 0; i < n_classes; i++) { + CBMLSPDef *cls = &defs[i * 3]; + cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); + cls->short_name = cls->qualified_name + strlen("scale.mod."); + cls->label = "Class"; + cls->def_module_qn = "scale.mod"; + cls->lang = CBM_LANG_PYTHON; + for (int j = 1; j <= 2; j++) { + CBMLSPDef *m = &defs[i * 3 + j]; + m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); + m->short_name = j == 1 ? "m1" : "m2"; + m->label = "Method"; + m->receiver_type = cls->qualified_name; + m->def_module_qn = "scale.mod"; + m->lang = CBM_LANG_PYTHON; + } + } + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); + clock_gettime(CLOCK_MONOTONIC, &t1); + if (out_types) + *out_types = reg ? reg->type_count : -1; + cbm_arena_destroy(&arena); + return elapsed_ms(t0, t1); +} + +/* The shared build registers defs one at a time into an unfinalized registry; + * a receiver probe that scans every type registered so far makes the build + * O(methods x types). 16x input: linear ~16x time, quadratic well over 100x. */ +TEST(pylsp_scale_cross_registry_build_linear) { + int ty_small = 0, ty_large = 0; + /* Best of three for the small size: it is short enough for host noise. */ + double t_small = measure_cross_registry(1000, &ty_small); + for (int k = 0; k < 2; k++) { + double t = measure_cross_registry(1000, NULL); + if (t >= 0.0 && t < t_small) + t_small = t; + } + double t_large = measure_cross_registry(16000, &ty_large); + printf(" cross registry: 1000 classes=%.1fms (types=%d) 16000 classes=%.1fms " + "(types=%d)\n", + t_small, ty_small, t_large, ty_large); + ASSERT(t_small >= 0.0 && t_large >= 0.0); + ASSERT_EQ(ty_large - ty_small, 15000); + if (t_small > 0.5) { + double ratio = t_large / t_small; + printf(" cross registry ratio 16000/1000: %.1fx (linear ~16x)\n", ratio); + ASSERT(ratio < 48.0); + } + PASS(); +} + SUITE(py_lsp_scale) { RUN_TEST(pylsp_scale_linear_growth); + RUN_TEST(pylsp_scale_cross_registry_build_linear); } From 3f53a308f4034f4d280130fbcc275f545d7bf05e Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 28 Sep 2026 08:59:32 -0500 Subject: [PATCH 06/16] perf(python): climb the walk cursor for scope-directive and default checks For nearly every Python identifier, lexical_ancestor_kind and python_default_value_reference climbed toward the root with ts_node_parent, which re-descends from the root on every step, so a deep expression was cubic. In error-free trees, climb the walk's occurrence cursor instead and take a single parent step for global/nonlocal names, which the grammar makes direct children; trees with errors keep the original climb. sympy's polys/numberfields/resolvent_lookup.py drops from about 8.7 s to 54 ms in the definitions pass, ordinary Python extraction is about 25% faster, and the graph is byte-identical. Signed-off-by: Nelson Spence --- internal/cbm/extract_usages.c | 73 +++++++++++++++---- tests/repro/repro_lexical_binding_precision.c | 54 ++++++++++++++ tests/test_extraction.c | 44 +++++++++++ 3 files changed, 155 insertions(+), 16 deletions(-) diff --git a/internal/cbm/extract_usages.c b/internal/cbm/extract_usages.c index bdad1c8c7..50f717872 100644 --- a/internal/cbm/extract_usages.c +++ b/internal/cbm/extract_usages.c @@ -1779,9 +1779,25 @@ static uint32_t active_lexical_scope_id(const WalkState *state) { static uint32_t lexical_ancestor_of_kind(const WalkState *state, uint32_t start_id, bool want_function, bool want_block); -static bool python_default_value_reference(TSNode node) { - for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); - parent = ts_node_parent(parent)) { +/* One step up: on the occurrence cursor when there is one, else with + * ts_node_parent, which descends from the ROOT, so a climb costs O(depth^2). */ +static TSNode usage_parent_step(TSTreeCursor *cursor, TSNode current) { + if (cursor) { + return ts_tree_cursor_goto_parent(cursor) ? ts_tree_cursor_current_node(cursor) + : (TSNode){0}; + } + usage_slow_parent_fallback_test_note(); + return ts_node_parent(current); +} + +/* Asked for every Python usage, and a deep expression puts O(depth) parents + * above each one. A tree without errors climbs them on the walk cursor, like + * python_scope_directive_kind; a tree with errors keeps ts_node_parent. */ +static bool python_default_value_reference(CBMExtractCtx *ctx, WalkState *state, TSNode node) { + TSTreeCursor *cursor = + ts_node_has_error(ctx->root) ? NULL : reset_occurrence_cursor(state, node); + for (TSNode parent = usage_parent_step(cursor, node); !ts_node_is_null(parent); + parent = usage_parent_step(cursor, parent)) { const char *kind = ts_node_type(parent); /* A nested executable scope inside the default owns its own lookups; * only a direct default expression is evaluated in the declaring @@ -1802,10 +1818,10 @@ static bool python_default_value_reference(TSNode node) { return false; } -static uint32_t usage_lexical_scope_id_for_node(CBMExtractCtx *ctx, const WalkState *state, - TSNode node) { +static uint32_t usage_lexical_scope_id_for_node(CBMExtractCtx *ctx, WalkState *state, TSNode node) { uint32_t scope_id = active_lexical_scope_id(state); - if (!ctx || ctx->language != CBM_LANG_PYTHON || !python_default_value_reference(node)) { + if (!ctx || ctx->language != CBM_LANG_PYTHON || + !python_default_value_reference(ctx, state, node)) { return scope_id; } uint32_t function_id = lexical_ancestor_of_kind(state, scope_id, true, false); @@ -1881,8 +1897,8 @@ static bool ensure_lexical_binding_capacity(WalkState *state) { } static bool lexical_ancestor_kind(TSNode node, const char *kind) { - for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); - parent = ts_node_parent(parent)) { + for (TSNode parent = usage_parent_step(NULL, node); !ts_node_is_null(parent); + parent = usage_parent_step(NULL, parent)) { if (strcmp(ts_node_type(parent), kind) == 0) { return true; } @@ -1890,6 +1906,34 @@ static bool lexical_ancestor_kind(TSNode node, const char *kind) { return false; } +/* Is `node` a name listed by a Python global/nonlocal statement? The grammar + * makes those names direct children of the statement, so in a tree without + * errors one parent step on the walk cursor answers it. The full climb above + * steps with ts_node_parent, which descends from the ROOT each time: O(depth^2) + * per identifier, asked for every Python identifier, so one deep arithmetic + * expression went cubic. Error recovery can put a name under an ERROR child of + * the statement, so a tree with errors keeps the full climb. */ +static CBMPythonDirectiveKind python_scope_directive_kind(CBMExtractCtx *ctx, WalkState *state, + TSNode node) { + if (ts_node_has_error(ctx->root)) { + if (lexical_ancestor_kind(node, "global_statement")) { + return CBM_PYTHON_DIRECTIVE_GLOBAL; + } + return lexical_ancestor_kind(node, "nonlocal_statement") ? CBM_PYTHON_DIRECTIVE_NONLOCAL + : 0; + } + TSNode parent; + const char *field; + if (!occurrence_parent(reset_occurrence_cursor(state, node), node, &parent, &field)) { + return 0; + } + const char *kind = ts_node_type(parent); + if (strcmp(kind, "global_statement") == 0) { + return CBM_PYTHON_DIRECTIVE_GLOBAL; + } + return strcmp(kind, "nonlocal_statement") == 0 ? CBM_PYTHON_DIRECTIVE_NONLOCAL : 0; +} + static TSNode declared_function_name_owner(TSNode node, const CBMLangSpec *spec) { for (TSNode parent = ts_node_parent(node); !ts_node_is_null(parent); parent = ts_node_parent(parent)) { @@ -2236,13 +2280,11 @@ static void record_lexical_binding(CBMExtractCtx *ctx, WalkState *state, TSNode TSNode function_declaration = declared_function_name_owner(node, spec); TSNode class_declaration = declared_class_name_owner(node, spec); - if (ctx->language == CBM_LANG_PYTHON && (lexical_ancestor_kind(node, "global_statement") || - lexical_ancestor_kind(node, "nonlocal_statement"))) { + CBMPythonDirectiveKind scope_directive = + ctx->language == CBM_LANG_PYTHON ? python_scope_directive_kind(ctx, state, node) : 0; + if (scope_directive) { uint32_t function_id = lexical_ancestor_of_kind(state, current_id, true, false); - CBMPythonDirectiveKind directive = lexical_ancestor_kind(node, "global_statement") - ? CBM_PYTHON_DIRECTIVE_GLOBAL - : CBM_PYTHON_DIRECTIVE_NONLOCAL; - record_python_directive(state, function_id, name, directive); + record_python_directive(state, function_id, name, scope_directive); return; } @@ -2709,8 +2751,7 @@ void handle_usages(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Wal } bool python_scope_directive = - ctx->language == CBM_LANG_PYTHON && (lexical_ancestor_kind(node, "global_statement") || - lexical_ancestor_kind(node, "nonlocal_statement")); + ctx->language == CBM_LANG_PYTHON && python_scope_directive_kind(ctx, state, node) != 0; if (python_scope_directive || is_binding_occurrence(ctx, node, spec, state)) { char *binding_name = reference_name(ctx, node); if (binding_name && binding_name[0] && !cbm_is_keyword(binding_name, ctx->language)) { diff --git a/tests/repro/repro_lexical_binding_precision.c b/tests/repro/repro_lexical_binding_precision.c index 6c014c2af..7550cb547 100644 --- a/tests/repro/repro_lexical_binding_precision.c +++ b/tests/repro/repro_lexical_binding_precision.c @@ -963,6 +963,59 @@ TEST(repro_python_global_assignment_is_module_binding) { PASS(); } +/* A global/nonlocal name is a directive, not a read (each name of `global a, + * b`), and the rebinding lands where it says: the module from that point on, + * or the enclosing function. Source 1 is an error tree, whose directive and + * default-value checks take the ts_node_parent fallback. */ +TEST(repro_python_scope_directives_bind_the_named_scope) { + static const char *const sources[] = { + "def handler():\n pass\n" + "def outer():\n handler = None\n" + " def inner():\n nonlocal handler\n handler = outer\n" + "def early():\n accept_early(handler)\n" + "def caller():\n def nested():\n global handler, other\n" + " handler = outer\n accept_outer(handler)\n" + "def sibling():\n accept_sibling(handler)\n", + "def handler():\n pass\n" + "def make(handler=handler):\n return handler\n" + "def caller():\n global handler other\n handler = make\n make(handler)\n", + }; + static const struct { + int source; + const char *marker, *name; + int count, blocked, shadow; + } sites[] = { + {0, "global handler", "handler", 0, 0, 0}, + {0, ", other", "other", 0, 0, 0}, + {0, "nonlocal handler", "handler", 0, 0, 0}, + {0, "accept_early(handler)", "handler", 1, 0, 0}, + {0, "accept_outer(handler)", "handler", 1, 1, 0}, + {0, "accept_sibling(handler)", "handler", 1, 1, 0}, + {1, "global handler", "handler", 0, 0, 0}, + {1, "=handler", "handler", 1, 0, 0}, + {1, "make(handler)", "handler", 1, 1, 0}, + }; + int failures = 0; + for (int i = 0; i < (int)(sizeof(sites) / sizeof(sites[0])); i++) { + const char *source = sources[sites[i].source]; + uint32_t start = lb_identifier_offset(source, sites[i].marker, sites[i].name); + ASSERT_NEQ(start, UINT32_MAX); + CBMFileResult *result = lb_extract(source, CBM_LANG_PYTHON, "directives.py"); + ASSERT_NOT_NULL(result); + ASSERT_EQ(result->parse_incomplete, sites[i].source); + LBBindingSite got = lb_binding_site(result, NULL, sites[i].name, start); + cbm_free_result(result); + if (got.count != sites[i].count || got.blocked_count != sites[i].blocked || + got.local_shadow_count != sites[i].shadow) { + printf(" %s: count %d blocked %d shadow %d\n", sites[i].marker, got.count, + got.blocked_count, got.local_shadow_count); + failures++; + } + } + ASSERT_EQ(failures, 0); + PASS(); +} + /* Module assignments participate in name lookup too. A later function must * not raw-name-fallback through a module variable to an older same-named * callable declaration. */ @@ -1055,6 +1108,7 @@ SUITE(repro_lexical_binding_precision) { RUN_TEST(repro_objectscript_udl_set_target_binds_method_local); RUN_TEST(repro_python_default_expression_uses_enclosing_namespace); RUN_TEST(repro_python_global_assignment_is_module_binding); + RUN_TEST(repro_python_scope_directives_bind_the_named_scope); RUN_TEST(repro_python_module_assignment_blocks_callable_fallback); RUN_TEST(repro_go_function_literal_parameter_does_not_leak); } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index acf5b2319..afc9f5536 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -7258,6 +7258,49 @@ TEST(extract_rescript_let_bindings_use_the_walk_cursor) { ASSERT_EQ(big_fallbacks, 0); PASS(); } + +/* Every Python usage asks whether it is a global/nonlocal name and whether it + * sits in a parameter default. Both climbed with ts_node_parent, which + * descends from the ROOT: O(d^2) for a usage d deep, so one deep expression + * went cubic (2.2 s / 17 s / >90 s at 500/1000/2000 terms). Count every such + * root-descending step at N and 2N terms. */ +TEST(extract_python_deep_default_parent_climbs_are_linear) { + enum { SMALL = 200 }; + uint64_t slow_parents[2]; + for (int round = 0; round < 2; round++) { + /* def f(x=x + x + ... + x): return x -- a binary_operator chain. */ + int terms = SMALL << round; + size_t capacity = 32U + (size_t)terms * 4U; + char *source = malloc(capacity); + ASSERT_NOT_NULL(source); + int length = snprintf(source, capacity, "def f(x=x"); + for (int i = 1; i < terms; i++) { + length += snprintf(source + length, capacity - (size_t)length, " + x"); + } + length += snprintf(source + length, capacity - (size_t)length, "):\n return x\n"); + cbm_usage_field_lookup_test_reset(); + CBMFileResult *result = + cbm_extract_file(source, length, CBM_LANG_PYTHON, "proj", "deep.py", 0, NULL, NULL); + free(source); + ASSERT_NOT_NULL(result); + slow_parents[round] = cbm_usage_slow_parent_fallback_test_count(); + /* Anti-vacuous: every default x climbed its chain to the parameter and + * reads the enclosing namespace; only `return x` is shadowed by it. */ + int defaults = 0; + for (int i = 0; i < result->usages.count; i++) { + const CBMUsage *usage = &result->usages.items[i]; + defaults += usage->ref_name && strcmp(usage->ref_name, "x") == 0 && + !usage->semantic_reference_local_shadow; + } + cbm_free_result(result); + ASSERT_EQ(defaults, terms); + } + fprintf(stderr, " [python-deep-default] slow_parents(%d)=%llu slow_parents(%d)=%llu\n", SMALL, + (unsigned long long)slow_parents[0], 2 * SMALL, (unsigned long long)slow_parents[1]); + /* Twice the terms may cost about twice the steps; the cubic climb took 4x. */ + ASSERT_LTE(slow_parents[1], slow_parents[0] * 3U + 64U); + PASS(); +} #endif /* =================================================================== @@ -8605,6 +8648,7 @@ SUITE(extraction) { RUN_TEST(extract_wide_flat_reference_fields_are_linear); RUN_TEST(extract_csharp_argument_values_use_the_walk_cursor); RUN_TEST(extract_rescript_let_bindings_use_the_walk_cursor); + RUN_TEST(extract_python_deep_default_parent_climbs_are_linear); #endif /* Perl call-graph noise (#459 follow-up) */ From c4b635b25f7a51670642e5100c6e73d6406be4a1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:24:56 +0200 Subject: [PATCH 07/16] fix(store,ui): route the paired-free allocations through the memory core The three exported release functions added for #1762 freed raw heap blocks. Since the memory-core ratchet landed, the allocating side has to go through the core as well, and on current main cbm_leiden already allocates its result in CBM_MEM_CLASS_OTHER, so a raw free() in cbm_leiden_free left the class counters drifting. - cbm_store_fetch_call_edges: allocate and grow the source/target arrays with cbm_alloc/cbm_realloc in CBM_MEM_CLASS_STORE. An allocation failure now finalizes the statement, releases both arrays and returns CBM_STORE_ERR instead of writing through a NULL array. - cbm_store_free_call_edges, cbm_leiden_free and cbm_layout_free_json release with cbm_free in the class the matching allocation used. - cbm_layout_to_json writes its output through cbm_json_writer_allocator, so the string is an OTHER-class core block; the UI server's five release sites now call cbm_layout_free_json. - The header comments name the paired free instead of "caller frees". - Tests: ownership checks for all three pairs measure the class counters across the allocating call and the paired free, so a raw allocation on either side fails; the call-edge check grows past the initial capacity. Refs #1762 Signed-off-by: Martin Vogel --- src/store/store.c | 39 ++++++++++++++++++++------ src/store/store.h | 5 ++-- src/ui/http_server.c | 10 +++---- src/ui/layout3d.c | 9 ++++-- src/ui/layout3d.h | 2 +- tests/test_store_arch.c | 62 ++++++++++++++++++++++++++++++++++++++++- tests/test_ui.c | 35 +++++++++++++++++++++++ 7 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index 92b78a2ca..069e06b1d 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4123,10 +4123,18 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge bind_text(stmt, SKIP_ONE, project); sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND); + /* Both arrays are STORE-class memory-core blocks: the caller releases them + * with cbm_store_free_call_edges, the only free that matches. */ int cap = ST_INIT_CAP_16; int n = 0; - int64_t *src = malloc((size_t)cap * sizeof(int64_t)); - int64_t *tgt = malloc((size_t)cap * sizeof(int64_t)); + int64_t *src = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + int64_t *tgt = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); + if (!src || !tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } int scan_rc; while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) { if (max_edges > 0 && n >= max_edges) { @@ -4136,9 +4144,23 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge break; } if (n >= cap) { + size_t grown_bytes = (size_t)cap * ST_GROWTH * sizeof(int64_t); + int64_t *grown_src = cbm_realloc(CBM_MEM_CLASS_STORE, src, grown_bytes); + if (grown_src) { + src = grown_src; + } + int64_t *grown_tgt = + grown_src ? cbm_realloc(CBM_MEM_CLASS_STORE, tgt, grown_bytes) : NULL; + if (grown_tgt) { + tgt = grown_tgt; + } + if (!grown_src || !grown_tgt) { + sqlite3_finalize(stmt); + cbm_store_free_call_edges(src, tgt); + store_set_error(s, "fetch_call_edges: out of memory"); + return CBM_STORE_ERR; + } cap *= ST_GROWTH; - src = safe_realloc(src, (size_t)cap * sizeof(int64_t)); - tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t)); } src[n] = sqlite3_column_int64(stmt, 0); tgt[n] = sqlite3_column_int64(stmt, 1); @@ -4146,8 +4168,7 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge } sqlite3_finalize(stmt); if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) { - free(src); - free(tgt); + cbm_store_free_call_edges(src, tgt); store_set_error_sqlite(s, "fetch_call_edges scan"); return CBM_STORE_ERR; } @@ -4158,8 +4179,8 @@ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edge } void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { - free(src); - free(tgt); + cbm_free(CBM_MEM_CLASS_STORE, src); + cbm_free(CBM_MEM_CLASS_STORE, tgt); } int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, @@ -10499,7 +10520,7 @@ int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t * } void cbm_leiden_free(cbm_louvain_result_t *result) { - free(result); + cbm_free(CBM_MEM_CLASS_OTHER, result); /* cbm_leiden allocates OTHER */ } /* ── Architecture: community clusters via Leiden ───────────────── */ diff --git a/src/store/store.h b/src/store/store.h index 3e2f3a546..59c281443 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -708,7 +708,8 @@ int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e); int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count); /* Fetch all CALLS edges among Function/Method nodes for a project as parallel - * (source_id, target_id) arrays (caller frees both). For SCC / cycle analysis. + * (source_id, target_id) arrays, released together with + * cbm_store_free_call_edges. For SCC / cycle analysis. * Stops at max_edges and sets *truncated — never a silent cap. Returns * CBM_STORE_OK (or _ERR); *count is the number returned. */ int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, @@ -1291,7 +1292,7 @@ typedef struct { * reported community is internally connected. The resolution parameter * controls granularity (higher -> more, smaller communities); 1.0 is standard. * Allocates *out (length *out_count == node_count) in CBM_MEM_CLASS_OTHER; - * the caller releases it with cbm_free(CBM_MEM_CLASS_OTHER, *out). */ + * the caller releases it with cbm_leiden_free(*out). */ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, double resolution, cbm_louvain_result_t **out, int *out_count); diff --git a/src/ui/http_server.c b/src/ui/http_server.c index ffc834f6d..6e153686e 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -2042,7 +2042,7 @@ static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm return false; } yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0); - free(mjson); + cbm_layout_free_json(mjson); if (!mldoc) { return false; } @@ -2164,7 +2164,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } cbm_http_replyf(c, 200, g_cors_json, "%s", scoped_json); - free(scoped_json); + cbm_layout_free_json(scoped_json); return; } @@ -2200,13 +2200,13 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { if (linked_count == 0 && missed_graph) { cbm_store_close(store); cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json); - free(primary_json); + cbm_layout_free_json(primary_json); return; } /* Parse primary JSON and append missed_graph + linked_projects */ yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0); - free(primary_json); + cbm_layout_free_json(primary_json); if (!pdoc) { cbm_store_close(store); cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}"); @@ -2258,7 +2258,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Parse linked project layout */ yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0); - free(lp_json); + cbm_layout_free_json(lp_json); if (!lpdoc) { cbm_store_close(lp_store); free(linked[li]); diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 89497837a..c9abaf773 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -14,6 +14,7 @@ #include "ui/layout3d.h" #include "ui/layout_internal.h" #include "foundation/log.h" +#include "foundation/json_writer.h" #include "foundation/mem_core.h" #include "foundation/platform.h" @@ -878,10 +879,12 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { yyjson_mut_obj_add_val(doc, root, "edges", ea); yyjson_mut_obj_add_int(doc, root, "total_nodes", r->total_nodes); + /* The output string is an OTHER-class memory-core block, released by + * cbm_layout_free_json; the document keeps its own allocator. */ size_t len = 0; yyjson_write_err write_err = {0}; - char *json = - yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL, &len, &write_err); + char *json = yyjson_mut_write_opts(doc, YYJSON_WRITE_ALLOW_INVALID_UNICODE, + cbm_json_writer_allocator(), &len, &write_err); yyjson_mut_doc_free(doc); if (!json) { char code[CBM_SZ_32]; @@ -893,5 +896,5 @@ char *cbm_layout_to_json(const cbm_layout_result_t *r) { } void cbm_layout_free_json(char *json) { - free(json); + cbm_free(CBM_MEM_CLASS_OTHER, json); } diff --git a/src/ui/layout3d.h b/src/ui/layout3d.h index 29b13712a..6d4cd22ed 100644 --- a/src/ui/layout3d.h +++ b/src/ui/layout3d.h @@ -69,7 +69,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, /* Release a computed layout result and its fields through their matching allocators. */ void cbm_layout_free(cbm_layout_result_t *result); -/* Serialize layout result to JSON string. Caller must free(). */ +/* Serialize layout result to JSON string. Release with cbm_layout_free_json(). */ char *cbm_layout_to_json(const cbm_layout_result_t *result); /* Free the JSON string returned by cbm_layout_to_json. */ diff --git a/tests/test_store_arch.c b/tests/test_store_arch.c index 50d434563..92af0994e 100644 --- a/tests/test_store_arch.c +++ b/tests/test_store_arch.c @@ -1431,6 +1431,64 @@ TEST(fetch_call_edges_empty_project) { PASS(); } +/* The two arrays cbm_store_fetch_call_edges hands out are memory-core blocks of + * the STORE class, and cbm_store_free_call_edges is what takes them back. Both + * halves are measured: the fetch adds exactly the two arrays and the paired free + * removes exactly those, so a raw allocation on either side fails. Thirty-six + * edges outgrow the initial capacity of 16, so the growth path is covered too. */ +TEST(fetch_call_edges_memory_core_ownership) { + enum { FN = 37 }; + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_store_upsert_project(s, "grow", "/tmp/grow"); + int64_t ids[FN]; + for (int i = 0; i < FN; i++) { + char name[32]; + char qn[64]; + snprintf(name, sizeof(name), "f%d", i); + snprintf(qn, sizeof(qn), "grow.pkg.f%d", i); + cbm_node_t n = {.project = "grow", + .label = "Function", + .name = name, + .qualified_name = qn, + .file_path = "pkg/f.go"}; + ids[i] = cbm_store_upsert_node(s, &n); + ASSERT_TRUE(ids[i] > 0); + } + for (int i = 0; i + 1 < FN; i++) { + cbm_edge_t e = { + .project = "grow", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"}; + ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0); + } + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE); + int64_t *src = NULL; + int64_t *tgt = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_fetch_call_edges(s, "grow", 100, &src, &tgt, &count, &truncated), + CBM_STORE_OK); + ASSERT_EQ(count, FN - 1); + ASSERT_FALSE(truncated); + ASSERT_NOT_NULL(src); + ASSERT_NOT_NULL(tgt); + /* Ordered by (source_id, target_id): growth kept every pair in place. */ + ASSERT_EQ(src[0], ids[0]); + ASSERT_EQ(tgt[0], ids[1]); + ASSERT_EQ(src[FN - 2], ids[FN - 2]); + ASSERT_EQ(tgt[FN - 2], ids[FN - 1]); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks + 2); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE) >= + bytes + 2 * (size_t)count * sizeof(int64_t)); + + cbm_store_free_call_edges(src, tgt); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks); + cbm_store_close(s); + PASS(); +} + /* ── Louvain tests ──────────────────────────────────────────────── */ TEST(louvain_basic) { @@ -1564,7 +1622,8 @@ TEST(leiden_result_memory_core_ownership) { ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + 3 * sizeof(*result)); - cbm_free(CBM_MEM_CLASS_OTHER, result); + /* Released through the exported pair, the way an FFI caller must. */ + cbm_leiden_free(result); ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); } @@ -2014,6 +2073,7 @@ SUITE(store_arch) { RUN_TEST(fetch_call_edges_basic); RUN_TEST(fetch_call_edges_truncated); RUN_TEST(fetch_call_edges_empty_project); + RUN_TEST(fetch_call_edges_memory_core_ownership); /* Louvain */ RUN_TEST(louvain_basic); diff --git a/tests/test_ui.c b/tests/test_ui.c index 4d31410d9..8c3a60006 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -562,6 +562,40 @@ TEST(layout_to_json) { PASS(); } +/* cbm_layout_to_json hands out one memory-core block of the OTHER class, and + * cbm_layout_free_json takes exactly that block back. A raw allocation on either + * side of the pair fails the count. */ +TEST(layout_json_memory_core_ownership) { + cbm_store_t *store = cbm_store_open_memory(); + ASSERT_NOT_NULL(store); + cbm_store_upsert_project(store, "test", "/tmp/test"); + cbm_node_t n = {.project = "test", + .label = "Function", + .name = "hello", + .qualified_name = "test::hello", + .file_path = "a.c", + .start_line = 1, + .end_line = 5}; + cbm_store_upsert_node(store, &n); + cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); + ASSERT_NOT_NULL(r); + + size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); + size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); + char *json = cbm_layout_to_json(r); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"hello\"") != NULL); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1); + ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >= bytes + strlen(json) + 1); + + cbm_layout_free_json(json); + ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes); + ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks); + cbm_layout_free(r); + cbm_store_close(store); + PASS(); +} + TEST(layout_null_inputs) { /* NULL store → NULL result */ cbm_layout_result_t *r = cbm_layout_compute(NULL, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); @@ -2698,6 +2732,7 @@ SUITE(ui) { RUN_TEST(layout_honors_budget_above_default); RUN_TEST(layout_deterministic); RUN_TEST(layout_to_json); + RUN_TEST(layout_json_memory_core_ownership); RUN_TEST(layout_null_inputs); RUN_TEST(layout_dead_code_classification); RUN_TEST(layout_coincident_nodes_bounded); From 3e2de74e985f3fa688db98a6b4bb6873d2c740d8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:26:31 +0200 Subject: [PATCH 08/16] feat(workspace): record broad approvals with allow-root --approve-broad Replace the PATH_ALLOW_BROAD environment variable with a recorded grant, the shape agreed in review: `codebase-memory-mcp allow-root --approve-broad ` records an explicit human approval for one root refused as too broad (CBM_WS_DENY_TOO_SHALLOW), mirroring --approve-sensitive end to end. - cbm_workspace_grant_add() takes approve_broad next to approve_sensitive. Each flag lifts only its own verdict, absolute denials stay unliftable, and a refusal names the flag that would lift it. - The approval is stored with a '^' marker, listed by allow-root --list as "(approved broad)", and honoured by cbm_workspace_root_allowed() only for CBM_WS_DENY_TOO_SHALLOW and only on an exact path match, keeping the exact-match rule of the original change. - cbm_workspace_root_allowed() returns to its previous signature. Every caller (MCP index_repository and index-job start, auto-index, daemon sessions, the HTTP UI) reads the same grant store, so stdio and daemon sessions decide identically and nothing depends on the environment a daemon was started with. - The too-broad refusal names `allow-root --approve-broad `, a command a person runs. - cbm_workspace_classify_root() and cbm_workspace_verdict_is_overridable() are unchanged. The regression test keeps its name and its exact-match, sibling, absolute and sensitive checks, now against the grant store, and adds that a broad marker never lifts a sensitive verdict. Co-authored-by: Mark Murawski Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 9 ++ src/daemon/application.c | 3 +- src/foundation/workspace.c | 169 ++++++++++++++++++++------------ src/foundation/workspace.h | 31 +++--- src/main.c | 15 ++- src/mcp/mcp.c | 9 +- src/ui/http_server.c | 3 +- tests/test_daemon_application.c | 10 +- tests/test_mcp.c | 2 +- tests/test_workspace.c | 109 ++++++++++++-------- 10 files changed, 227 insertions(+), 133 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3e9956d60..04afa5b3e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -237,6 +237,15 @@ root because they are too broad or too sensitive to index as a unit: - a credential directory at any depth — `.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.netrc`, `.git-credentials`, `.password-store`, macOS `Keychains`. +A single top-level directory can still be indexed as one root on purpose, for +example a container or build machine that keeps its project in `/work`, `/app` or +`/src`, by approving that exact path in a terminal: +`codebase-memory-mcp allow-root --approve-broad /work`. The approval is recorded +like any other allowed root, listed as `(approved broad)` by `allow-root --list`, +and covers no other top-level directory; as with any recorded root, indexing is +then confined to the recorded roots. Filesystem, drive and share roots cannot be +approved. + Two limits are worth stating plainly. This constrains *scope*, not *sensitivity*: inside a root that is allowed, every file the process can read may be indexed and later returned. And the credential list is a denylist, so it diff --git a/src/daemon/application.c b/src/daemon/application.c index 0fd66e674..22b6f0d1a 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -521,8 +521,7 @@ static bool application_session_workspace_allowed(const cbm_daemon_application_s bool allowed = root && root[0] && cbm_workspace_root_allowed(root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - cbm_mcp_server_allowed_root(session->mcp), - getenv("PATH_ALLOW_BROAD"), boundary_error, + cbm_mcp_server_allowed_root(session->mcp), boundary_error, sizeof(boundary_error)); if (!allowed) { cbm_log_warn("daemon.workspace.skipped", "operation", operation, "detail", diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index ab10d4f1f..3f77c4879 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -383,8 +383,18 @@ enum { WS_LINE_MAX = 4096 }; /* A line may carry a leading '!' meaning "a person explicitly approved this * sensitive root". The marker is stored rather than inferred so re-classifying * later (a new credential name is added to the list, say) cannot silently - * upgrade an ordinary grant into a sensitive one. */ + * upgrade an ordinary grant into a sensitive one. A leading '^' is the same + * kind of record for a root refused as too broad (allow-root --approve-broad). + * Neither character can begin a canonical absolute path, so a marked line can + * never be misread as an ordinary grant. */ static const char WS_SENSITIVE_MARK = '!'; +static const char WS_BROAD_MARK = '^'; + +typedef enum { + WS_GRANT_ORDINARY = 0, + WS_GRANT_SENSITIVE, /* explicit approval of a CBM_WS_DENY_SENSITIVE root */ + WS_GRANT_BROAD, /* explicit approval of a CBM_WS_DENY_TOO_SHALLOW root */ +} ws_grant_kind_t; bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { if (!cache_dir || !cache_dir[0] || !out || out_sz == 0) { @@ -397,7 +407,8 @@ bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { /* Walk the grant file, invoking visit for each entry. visit returns true to stop. * Returns the number of entries seen. */ static int ws_grant_walk(const char *cache_dir, - bool (*visit)(const char *root, bool sensitive, void *ctx), void *ctx) { + bool (*visit)(const char *root, ws_grant_kind_t kind, void *ctx), + void *ctx) { char store[WS_LINE_MAX]; if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) { return 0; @@ -416,13 +427,18 @@ static int ws_grant_walk(const char *cache_dir, if (len == 0 || line[0] == '#') { continue; } - bool sensitive = line[0] == WS_SENSITIVE_MARK; - const char *root = sensitive ? line + 1 : line; + ws_grant_kind_t kind = WS_GRANT_ORDINARY; + if (line[0] == WS_SENSITIVE_MARK) { + kind = WS_GRANT_SENSITIVE; + } else if (line[0] == WS_BROAD_MARK) { + kind = WS_GRANT_BROAD; + } + const char *root = kind == WS_GRANT_ORDINARY ? line : line + 1; if (!root[0]) { continue; } seen++; - if (visit && visit(root, sensitive, ctx)) { + if (visit && visit(root, kind, ctx)) { break; } } @@ -434,14 +450,21 @@ typedef struct { const char *candidate; bool contained; /* candidate is at or below some granted root */ bool exact_sensitive; /* candidate is itself a grant marked sensitive */ + bool exact_broad; /* candidate is itself a grant marked broad */ } ws_match_t; -static bool ws_match_visit(const char *root, bool sensitive, void *ctx) { +static bool ws_match_visit(const char *root, ws_grant_kind_t kind, void *ctx) { ws_match_t *m = ctx; if (cbm_path_within_root(root, m->candidate)) { m->contained = true; - if (sensitive && ws_paths_equal(root, m->candidate)) { - m->exact_sensitive = true; + /* Exact equality, never a prefix: an approval names one root, and must + * not quietly cover every root below it. */ + if (kind != WS_GRANT_ORDINARY && ws_paths_equal(root, m->candidate)) { + if (kind == WS_GRANT_SENSITIVE) { + m->exact_sensitive = true; + } else { + m->exact_broad = true; + } } } return false; /* visit every entry: a later one may be the exact match */ @@ -453,10 +476,15 @@ typedef struct { size_t used; } ws_list_t; -static bool ws_list_visit(const char *root, bool sensitive, void *ctx) { +static bool ws_list_visit(const char *root, ws_grant_kind_t kind, void *ctx) { ws_list_t *l = ctx; - int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", - sensitive ? "(approved) " : "", root); + const char *label = ""; + if (kind == WS_GRANT_SENSITIVE) { + label = "(approved) "; + } else if (kind == WS_GRANT_BROAD) { + label = "(approved broad) "; + } + int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", label, root); if (n > 0 && (size_t)n < l->out_sz - l->used) { l->used += (size_t)n; } @@ -472,9 +500,49 @@ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz) { return ws_grant_walk(cache_dir, ws_list_visit, &l) > 0; } +/* The allow-root flag that records an explicit approval for a refused verdict, + * or NULL when nothing can lift it (absolute denials). */ +static const char *ws_verdict_approval_flag(cbm_ws_verdict_t verdict) { + if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + return "--approve-broad"; + } + if (cbm_workspace_verdict_is_overridable(verdict)) { + return "--approve-sensitive"; + } + return NULL; +} + +/* True when a refused verdict carries the explicit approval that lifts it. Each + * flag lifts only its own verdict: --approve-sensitive never widens breadth and + * --approve-broad never widens sensitivity; absolute denials lift for neither. + * Refuses by default and names the flag, rather than asking a question whose + * answer we cannot authenticate. */ +static bool ws_grant_verdict_approved(cbm_ws_verdict_t verdict, bool approve_sensitive, + bool approve_broad, char *err, size_t err_sz) { + bool approved = false; + if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + approved = approve_broad; + } else if (cbm_workspace_verdict_is_overridable(verdict)) { + approved = approve_sensitive; + } + if (approved) { + return true; + } + if (err) { + const char *flag = ws_verdict_approval_flag(verdict); + if (flag) { + snprintf(err, err_sz, "%s; re-run with %s if that is intended", + cbm_workspace_verdict_reason(verdict), flag); + } else { + snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); + } + } + return false; +} + bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, - const char *canonical_path, bool approve_sensitive, char *err, - size_t err_sz) { + const char *canonical_path, bool approve_sensitive, bool approve_broad, + char *err, size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -486,33 +554,22 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir); - if (verdict != CBM_WS_ALLOW) { - if (!cbm_workspace_verdict_is_overridable(verdict)) { - if (err) { - snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); - } - return false; - } - if (!approve_sensitive) { - /* Refuse by default and name the flag, rather than asking a question - * whose answer we cannot authenticate. */ - if (err) { - snprintf(err, err_sz, "%s; re-run with --approve-sensitive if that is intended", - cbm_workspace_verdict_reason(verdict)); - } - return false; - } + if (verdict != CBM_WS_ALLOW && + !ws_grant_verdict_approved(verdict, approve_sensitive, approve_broad, err, err_sz)) { + return false; } bool sensitive = verdict == CBM_WS_DENY_SENSITIVE; + bool broad = verdict == CBM_WS_DENY_TOO_SHALLOW; - /* Already present is success, not a duplicate error. A sensitive approval + /* Already present is success, not a duplicate error. An explicit approval * is narrower: an old ordinary grant (or an ordinary ancestor) establishes * containment, but does not record the exact human-approved exception that * cbm_workspace_root_allowed requires. Append that exact marked entry once. */ - ws_match_t existing = {canonical_path, false, false}; + ws_match_t existing = {canonical_path, false, false, false}; (void)ws_grant_walk(cache_dir, ws_match_visit, &existing); - if (existing.contained && (!sensitive || existing.exact_sensitive)) { + if (existing.contained && (!sensitive || existing.exact_sensitive) && + (!broad || existing.exact_broad)) { return true; } @@ -530,7 +587,13 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } return false; } - (void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path); + char mark[2] = {'\0', '\0'}; + if (sensitive) { + mark[0] = WS_SENSITIVE_MARK; + } else if (broad) { + mark[0] = WS_BROAD_MARK; + } + (void)fprintf(f, "%s%s\n", mark, canonical_path); bool ok = fclose(f) == 0; if (!ok && err) { snprintf(err, err_sz, "cannot write %s", store); @@ -539,8 +602,8 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, - const char *cache_dir, const char *configured_root, - const char *allow_broad_root, char *err, size_t err_sz) { + const char *cache_dir, const char *configured_root, char *err, + size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -551,7 +614,7 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return false; } - ws_match_t match = {canonical_path, false, false}; + ws_match_t match = {canonical_path, false, false, false}; int grants = ws_grant_walk(cache_dir, ws_match_visit, &match); /* A configured root behaves as an additional grant so existing @@ -596,37 +659,23 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return true; } /* An explicit human approval recorded for exactly this path is the only thing - * that lifts a sensitive refusal. Absolute refusals cannot be lifted at all. */ + * that lifts a sensitive or a too-broad refusal, each by its own marker: a + * broad approval says "index this whole tree on purpose", which is not a + * statement about credentials, and the reverse. Exact match, never a prefix, + * so one broad approval cannot cover every root below it — the "/Users"-style + * breadth the depth rule exists to catch. Absolute refusals cannot be lifted. */ if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) { return true; } - /* PATH_ALLOW_BROAD is a separate, narrower lever from the grant store above: - * an operator has named this exact root as one they have deliberately chosen - * to index whole, accepting that it may span multiple unrelated projects -- - * the tradeoff CBM_WS_DENY_TOO_SHALLOW normally refuses on their behalf. It - * must match canonical_path exactly, the same way an exact sensitive grant - * does above: a prefix match would let one broad allowance quietly cover - * every root below it too, which is the "/Users"-style breadth this rule - * exists to catch. It is environment, not a recorded grant, so it is not - * listed by cbm_workspace_grant_list and cannot lift CBM_WS_DENY_ABSOLUTE or - * CBM_WS_DENY_SENSITIVE. */ - if (verdict == CBM_WS_DENY_TOO_SHALLOW && allow_broad_root && allow_broad_root[0] && - ws_paths_equal(canonical_path, allow_broad_root)) { + if (verdict == CBM_WS_DENY_TOO_SHALLOW && match.exact_broad) { return true; } if (err) { - if (cbm_workspace_verdict_is_overridable(verdict)) { + const char *flag = ws_verdict_approval_flag(verdict); + if (flag) { snprintf(err, err_sz, - "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root " - "--approve-sensitive %s", - canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); - } else if (verdict == CBM_WS_DENY_TOO_SHALLOW) { - /* No allow-root command exists for this one -- the grant store is a - * human-approved list of real project roots, and this path is being - * refused for being the opposite of that. PATH_ALLOW_BROAD is the - * deliberate, narrower escape hatch: set it to this exact path. */ - snprintf(err, err_sz, "%s: %s. To index it anyway, set PATH_ALLOW_BROAD=%s", - canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); + "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root %s %s", + canonical_path, cbm_workspace_verdict_reason(verdict), flag, canonical_path); } else { snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict)); } diff --git a/src/foundation/workspace.h b/src/foundation/workspace.h index 99dcb8fea..a956529ab 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -25,7 +25,9 @@ typedef enum { CBM_WS_ALLOW = 0, - /* Fewer path components than the platform minimum — e.g. "/etc", "/home". */ + /* Fewer path components than the platform minimum — e.g. "/etc", "/home". + * Lifted only for one exact path by a recorded broad approval + * (allow-root --approve-broad), never by a manifest or a tool call. */ CBM_WS_DENY_TOO_SHALLOW, /* A filesystem root, a drive root, a UNC share root, or a path that is or * contains the cbm cache directory. Never overridable. */ @@ -42,8 +44,10 @@ cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const c /* Stable, human-facing reason for a verdict. Never NULL. */ const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict); -/* True when an explicit human action may override this verdict. Absolute denials - * and shallow paths are never overridable; sensitive ones are. */ +/* True when a sensitive approval (allow-root --approve-sensitive) may override + * this verdict. Absolute denials never are. A shallow path is not overridable + * in this sense either: only an exact broad approval lifts it, see + * cbm_workspace_grant_add. */ bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict); /* Number of path components below the volume root, ignoring repeated and @@ -81,11 +85,13 @@ bool cbm_path_within_root(const char *root_path, const char *abs_path); bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz); /* Record canonical_path as an allowed root. approve_sensitive records an explicit - * human override for a CBM_WS_DENY_SENSITIVE path; absolute denials and shallow - * paths are never grantable. Returns false and fills err on refusal. */ + * human override for a CBM_WS_DENY_SENSITIVE path; approve_broad records one for a + * CBM_WS_DENY_TOO_SHALLOW path. Each flag lifts only its own verdict, and only + * for exactly this path. Absolute denials are never grantable. Returns false and + * fills err on refusal. */ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, - const char *canonical_path, bool approve_sensitive, char *err, - size_t err_sz); + const char *canonical_path, bool approve_sensitive, bool approve_broad, + char *err, size_t err_sz); /* Newline-separated granted roots into out. True when at least one exists. */ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz); @@ -93,15 +99,10 @@ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz); /* The whole decision, used by every entry point that accepts a repo path. * canonical_path must already be canonicalized. configured_root is the legacy * CBM_ALLOWED_ROOT / session policy, treated as an additional grant, or NULL. - * allow_broad_root is PATH_ALLOW_BROAD (or NULL): an operator-set exact match - * for canonical_path that lifts a CBM_WS_DENY_TOO_SHALLOW verdict only. It is - * process environment, not a recorded grant, so it does not appear in - * cbm_workspace_grant_list and does not widen CBM_WS_DENY_ABSOLUTE or - * CBM_WS_DENY_SENSITIVE. On refusal, err receives a message that names the - * command which would fix it. */ + * On refusal, err receives a message that names the command which would fix it. */ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, - const char *cache_dir, const char *configured_root, - const char *allow_broad_root, char *err, size_t err_sz); + const char *cache_dir, const char *configured_root, char *err, + size_t err_sz); /* The home and cache directories the policy should be evaluated against. Shared * so two callers cannot derive them differently and reach different verdicts for diff --git a/src/main.c b/src/main.c index 54bad875e..8f31d3d79 100644 --- a/src/main.c +++ b/src/main.c @@ -1175,21 +1175,27 @@ static void print_help(void) { /* Try to handle a subcommand (cli/install/uninstall/update/config/--version/--help). * Returns -1 if no subcommand matched, otherwise the exit code. */ -/* `allow-root [--approve-sensitive] ` — record an indexing root. +/* `allow-root [--approve-sensitive | --approve-broad] ` — record an + * indexing root. * * Enrollment lives here, in a command a person types, and deliberately nowhere * else: the whole point of the grant store is that neither an indexed repository * nor a tool caller can widen its own boundary. A confirmation delivered through * the MCP surface would be answered by the same agent that may have been - * influenced, so it would not be a human decision at all. */ + * influenced, so it would not be a human decision at all. --approve-broad is the + * same kind of record for one exact root refused as too broad ("/work", "/app"), + * so it is listed, revocable and seen identically by stdio and daemon sessions. */ static int main_run_allow_root(int argc, char **argv) { const char *path = NULL; bool approve_sensitive = false; + bool approve_broad = false; bool list_only = false; bool approve_manifest = false; for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--approve-sensitive") == 0) { approve_sensitive = true; + } else if (strcmp(argv[i], "--approve-broad") == 0) { + approve_broad = true; } else if (strcmp(argv[i], "--list") == 0) { list_only = true; } else if (strcmp(argv[i], "--approve-manifest") == 0) { @@ -1221,7 +1227,8 @@ static int main_run_allow_root(int argc, char **argv) { } if (!path && !list_only) { (void)fprintf(stderr, - "usage: codebase-memory-mcp allow-root [--approve-sensitive] \n" + "usage: codebase-memory-mcp allow-root [--approve-sensitive | " + "--approve-broad] \n" " codebase-memory-mcp allow-root --approve-manifest \n" " codebase-memory-mcp allow-root --list\n"); return EXIT_FAILURE; @@ -1264,7 +1271,7 @@ static int main_run_allow_root(int argc, char **argv) { char err[CBM_SZ_1K]; if (!cbm_workspace_grant_add(cache_dir, cbm_workspace_home_dir(), canonical, approve_sensitive, - err, sizeof(err))) { + approve_broad, err, sizeof(err))) { (void)fprintf(stderr, "refused: %s\n", err[0] ? err : "not an allowable root"); return EXIT_FAILURE; } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cf8ade005..f6e43b317 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -12214,8 +12214,7 @@ static char *index_status_project_key(cbm_mcp_server_t *srv, const char *args, c srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT"); if (repo_path && repo_path[0] && !cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - allowed_root, getenv("PATH_ALLOW_BROAD"), error_out, - error_size)) { + allowed_root, error_out, error_size)) { safe_free(repo_path); return NULL; } @@ -12318,8 +12317,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { char boundary_err[CBM_SZ_1K]; if (repo_path && repo_path[0] && !cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - allowed_root, getenv("PATH_ALLOW_BROAD"), boundary_err, - sizeof(boundary_err))) { + allowed_root, boundary_err, sizeof(boundary_err))) { index_args_free(repo_path, mode_str, name_override); return cbm_mcp_text_result(boundary_err, true); } @@ -20828,8 +20826,7 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT"); char boundary_err[CBM_SZ_1K]; if (!cbm_workspace_root_allowed(srv->session_root, cbm_workspace_home_dir(), - cbm_workspace_cache_dir(), allowed_root, - getenv("PATH_ALLOW_BROAD"), boundary_err, + cbm_workspace_cache_dir(), allowed_root, boundary_err, sizeof(boundary_err))) { cbm_log_warn("autoindex.skip", "reason", "workspace_boundary", "detail", boundary_err); return; diff --git a/src/ui/http_server.c b/src/ui/http_server.c index e2b87a4f3..ffc834f6d 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1713,8 +1713,7 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, } if (!cbm_workspace_root_allowed(canonical_root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), getenv("CBM_ALLOWED_ROOT"), - getenv("PATH_ALLOW_BROAD"), boundary_err, - sizeof(boundary_err))) { + boundary_err, sizeof(boundary_err))) { yyjson_doc_free(doc); char escaped[1024]; cbm_json_escape(escaped, (int)sizeof(escaped), boundary_err); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index a923043ca..4faea87a9 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -2594,8 +2594,9 @@ TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls) app_wait_for_active_jobs(application, 0); char grant_error[APP_TEST_PATH_CAP]; - bool approved = ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true, - grant_error, sizeof(grant_error)); + bool approved = + ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); bool approved_initialized = approved && app_test_initialize_profile(&callbacks, approved_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); @@ -2720,8 +2721,9 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) { bool ordinary_released = ordinary_watched && cbm_watcher_watch_count(watcher) == 0; char grant_error[APP_TEST_PATH_CAP]; - bool approved = ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true, - grant_error, sizeof(grant_error)); + bool approved = + ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); bool approved_initialized = approved && app_test_initialize_profile(&callbacks, approved_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 1ced07c95..536dbbc53 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2045,7 +2045,7 @@ static int issue403_initialize_count_calls(const char *session_root, bool approv char err[1024]; bool approved = !approve_sensitive || cbm_workspace_grant_add(cache, cbm_workspace_home_dir(), session_root, - true, err, sizeof(err)); + true, false, err, sizeof(err)); cbm_config_t *cfg = approved ? cbm_config_open(cache) : NULL; cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; int calls = -2; diff --git a/tests/test_workspace.c b/tests/test_workspace.c index 70298e7b8..4e1ca77c2 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -75,11 +75,11 @@ TEST(ws_posix_top_level_trees_are_too_shallow) { PASS(); } -/* PATH_ALLOW_BROAD is deliberately not part of the grant store's overridability: - * cbm_workspace_classify_root and cbm_workspace_verdict_is_overridable must stay - * exactly as strict as the test above already checked. Only the top-level - * decision, cbm_workspace_root_allowed, knows about it, and only as an exact - * match. */ +/* A recorded broad approval (allow-root --approve-broad) is the only thing that + * lifts CBM_WS_DENY_TOO_SHALLOW, and only for that exact path. Classification + * and cbm_workspace_verdict_is_overridable stay exactly as strict as the test + * above checks; the approval is a grant, so it is listed like any other human + * decision, and it lifts no other verdict. */ TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { char cache[256]; char *created = th_mktempdir("cbm_ws_allow_broad"); @@ -87,29 +87,58 @@ TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { snprintf(cache, sizeof(cache), "%s", created); char err[1024]; - ASSERT_FALSE(cbm_workspace_root_allowed("/srv", "/home/dev", cache, NULL, NULL, err, - sizeof(err))); - ASSERT_NOT_NULL(strstr(err, "PATH_ALLOW_BROAD")); + char listed[4096]; + /* Refused out of the box, and the refusal names the command a person runs. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/usr", HOME, cache, NULL, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "allow-root --approve-broad /usr")); - ASSERT_TRUE( - cbm_workspace_root_allowed("/srv", "/home/dev", cache, NULL, "/srv", err, sizeof(err))); - - /* A grant for the root does not extend to a sibling: PATH_ALLOW_BROAD names - * one path, not a prefix. */ - ASSERT_FALSE(cbm_workspace_root_allowed("/opt", "/home/dev", cache, NULL, "/srv", err, - sizeof(err))); - - /* Sensitive and absolute refusals are a different verdict and stay refused - * even when PATH_ALLOW_BROAD happens to name that exact path. */ - ASSERT_FALSE(cbm_workspace_root_allowed("/home/dev", "/home/dev", cache, NULL, "/home/dev", - err, sizeof(err))); - ASSERT_FALSE( - cbm_workspace_root_allowed("/", "/home/dev", cache, NULL, "/", err, sizeof(err))); - - /* Environment, not a grant: it never shows up in the recorded list. */ - char listed[64]; + /* Neither an ordinary grant nor a sensitive approval lifts breadth; a broad + * approval lifts neither an absolute nor a sensitive verdict. A refused grant + * records nothing. */ + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/usr", false, false, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-broad")); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/usr", true, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/", false, true, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, HOME, false, true, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); ASSERT_FALSE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + /* The explicit approval is recorded and marked. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, "/usr", false, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + ASSERT_STR_EQ(listed, "(approved broad) /usr\n"); + +#ifndef _WIN32 + /* Containment resolves real paths, so the rest needs a top-level directory + * that exists on every POSIX host: "/usr". A repeated approval is recognized + * rather than appended again. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, "/usr", false, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + ASSERT_STR_EQ(listed, "(approved broad) /usr\n"); + + /* It lifts exactly that path. A configured root of "/" puts "/dev" inside the + * declared boundary too, so the only thing still refusing it is that the + * approval names another path. */ + ASSERT_TRUE(cbm_workspace_root_allowed("/usr", HOME, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed("/usr", HOME, cache, "/", err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed("/dev", HOME, cache, "/", err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "allow-root --approve-broad /dev")); +#endif + + /* Each marker lifts only its own verdict: a broad mark on a credential + * directory, which grant_add would never write, still does not lift it. */ + char secret[1024]; + snprintf(secret, sizeof(secret), "%s/.ssh", cache); + ASSERT_EQ(cbm_mkdir(secret), 0); + char store[1024]; + ASSERT_TRUE(cbm_workspace_grant_path(cache, store, sizeof(store))); + FILE *f = cbm_fopen(store, "a"); + ASSERT_NOT_NULL(f); + (void)fprintf(f, "^%s\n", secret); + ASSERT_EQ(fclose(f), 0); + ASSERT_FALSE(cbm_workspace_root_allowed(secret, HOME, cache, NULL, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); + th_cleanup(cache); PASS(); } @@ -214,10 +243,10 @@ TEST(ws_sensitive_root_explicit_approval_is_preserved) { snprintf(cache, sizeof(cache), "%s", created); char err[1024]; - ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); - ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); th_cleanup(root); th_cleanup(cache); @@ -236,14 +265,14 @@ TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant) { snprintf(cache, sizeof(cache), "%s", created); char err[1024]; - ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, err, sizeof(err))); - ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); /* A repeated explicit approval must recognize the marked exact grant rather * than append another exception. */ - ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); char listed[4096]; char expected[4096]; @@ -271,13 +300,15 @@ TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor) { snprintf(sensitive, sizeof(sensitive), "%s/private-project", ancestor); ASSERT_EQ(cbm_mkdir(sensitive), 0); char err[1024]; - ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, err, sizeof(err))); - ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); ASSERT_TRUE( - cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); + cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, err, sizeof(err))); + ASSERT_TRUE( + cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); + ASSERT_TRUE( + cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, err, sizeof(err))); char listed[4096]; char expected[4096]; @@ -719,7 +750,7 @@ TEST(ws_manifest_ordinary_entries_unchanged) { char err[1024]; /* The grant store names a root the sdk is not under. */ - ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, false, err, sizeof(err))); ws_manifest_write_line(base, sdk); ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err))); From f08dadae9c6b439e167c8cebbf429e6aad1b5bff Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:29:02 +0200 Subject: [PATCH 09/16] test(py-lsp): gate the shared cross-registry build on counted work Replace the wall-clock ratio test pylsp_scale_cross_registry_build_linear (py_lsp_scale suite) with pylsp_cross_registry_build_work_is_linear in the py_lsp suite. The new test builds the same generated input (n classes, each followed by two methods, in extraction order) and reads the seam-build CBM_LSP_WORK counter instead of a clock, so its verdict does not depend on host load, and it runs in PR CI, which skips the perf suites. 4x the classes must cost at most ~4x the counted work. With the type-QN set the build examines no registry candidates (0 at 1000 and 4000 classes); with the scanning receiver probe the work grows from 2,809,000 to 23,236,000 and the test fails. Signed-off-by: Martin Vogel --- tests/test_py_lsp.c | 54 ++++++++++++++++++++++++++++++++ tests/test_py_lsp_scale.c | 66 --------------------------------------- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 85b17c9d6..b64b1dce2 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -2483,6 +2483,59 @@ TEST(pylsp_scale_work_is_linear) { PASS(); } +/* #2413 (O9: no clock): the shared Tier-2 build registers one def at a time + * into a registry it finalizes only at the end, so a receiver probe that scans + * the types registered so far makes it O(methods x types). The registry + * primitives count every candidate they examine (CBM_LSP_WORK): 4x the classes + * must cost at most ~4x the work; the scanning probe lands at ~16x. */ +static uint64_t cross_registry_work_for(int n_classes, int *out_types) { + CBMArena arena; + cbm_arena_init(&arena); + int n_defs = n_classes * 3; + CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); + if (!defs) { + cbm_arena_destroy(&arena); + *out_types = -1; + return 0; + } + /* Extraction order: each class followed by its two methods. */ + for (int i = 0; i < n_classes; i++) { + CBMLSPDef *cls = &defs[i * 3]; + cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); + cls->short_name = cls->qualified_name + strlen("scale.mod."); + cls->label = "Class"; + cls->def_module_qn = "scale.mod"; + cls->lang = CBM_LANG_PYTHON; + for (int j = 1; j <= 2; j++) { + CBMLSPDef *m = &defs[i * 3 + j]; + m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); + m->short_name = j == 1 ? "m1" : "m2"; + m->label = "Method"; + m->receiver_type = cls->qualified_name; + m->def_module_qn = "scale.mod"; + m->lang = CBM_LANG_PYTHON; + } + } + (void)cbm_lsp_work_take(); + CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); + uint64_t work = cbm_lsp_work_take(); + *out_types = reg ? reg->type_count : -1; + cbm_arena_destroy(&arena); + return work; +} + +TEST(pylsp_cross_registry_build_work_is_linear) { + int t1 = 0, t4 = 0; + uint64_t w1 = cross_registry_work_for(1000, &t1); + uint64_t w4 = cross_registry_work_for(4000, &t4); + printf(" cross registry work: 1000=%llu (types=%d) 4000=%llu (types=%d)\n", + (unsigned long long)w1, t1, (unsigned long long)w4, t4); + /* Every class registered exactly once (no duplicate placeholder). */ + ASSERT_EQ(t4 - t1, 3000); + ASSERT(w4 <= 5 * w1 + 1000); + PASS(); +} + extern const TSLanguage *tree_sitter_python(void); TEST(pylsp_issue1527_memo_allocation_failure_is_reported) { @@ -4885,6 +4938,7 @@ SUITE(py_lsp) { RUN_TEST(pylsp_issue1527_long_binary_chain_is_not_nesting); RUN_TEST(pylsp_issue1527_long_receiver_chain_is_not_nesting); RUN_TEST(pylsp_scale_work_is_linear); + RUN_TEST(pylsp_cross_registry_build_work_is_linear); /* Phase 2 — smoke */ RUN_TEST(pylsp_smoke_empty); RUN_TEST(pylsp_smoke_one_function); diff --git a/tests/test_py_lsp_scale.c b/tests/test_py_lsp_scale.c index 8654214bf..1e11e4ae7 100644 --- a/tests/test_py_lsp_scale.c +++ b/tests/test_py_lsp_scale.c @@ -111,72 +111,6 @@ TEST(pylsp_scale_linear_growth) { PASS(); } -/* Time cbm_py_build_cross_registry over n classes, each followed by two - * methods (extraction order): every method probes the registry for its - * receiver while the registry is still being built. */ -static double measure_cross_registry(int n_classes, int *out_types) { - CBMArena arena; - cbm_arena_init(&arena); - int n_defs = n_classes * 3; - CBMLSPDef *defs = (CBMLSPDef *)cbm_arena_calloc(&arena, (size_t)n_defs * sizeof(*defs)); - if (!defs) { - cbm_arena_destroy(&arena); - return -1.0; - } - for (int i = 0; i < n_classes; i++) { - CBMLSPDef *cls = &defs[i * 3]; - cls->qualified_name = cbm_arena_sprintf(&arena, "scale.mod.Cls%d", i); - cls->short_name = cls->qualified_name + strlen("scale.mod."); - cls->label = "Class"; - cls->def_module_qn = "scale.mod"; - cls->lang = CBM_LANG_PYTHON; - for (int j = 1; j <= 2; j++) { - CBMLSPDef *m = &defs[i * 3 + j]; - m->qualified_name = cbm_arena_sprintf(&arena, "%s.m%d", cls->qualified_name, j); - m->short_name = j == 1 ? "m1" : "m2"; - m->label = "Method"; - m->receiver_type = cls->qualified_name; - m->def_module_qn = "scale.mod"; - m->lang = CBM_LANG_PYTHON; - } - } - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); - CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, n_defs); - clock_gettime(CLOCK_MONOTONIC, &t1); - if (out_types) - *out_types = reg ? reg->type_count : -1; - cbm_arena_destroy(&arena); - return elapsed_ms(t0, t1); -} - -/* The shared build registers defs one at a time into an unfinalized registry; - * a receiver probe that scans every type registered so far makes the build - * O(methods x types). 16x input: linear ~16x time, quadratic well over 100x. */ -TEST(pylsp_scale_cross_registry_build_linear) { - int ty_small = 0, ty_large = 0; - /* Best of three for the small size: it is short enough for host noise. */ - double t_small = measure_cross_registry(1000, &ty_small); - for (int k = 0; k < 2; k++) { - double t = measure_cross_registry(1000, NULL); - if (t >= 0.0 && t < t_small) - t_small = t; - } - double t_large = measure_cross_registry(16000, &ty_large); - printf(" cross registry: 1000 classes=%.1fms (types=%d) 16000 classes=%.1fms " - "(types=%d)\n", - t_small, ty_small, t_large, ty_large); - ASSERT(t_small >= 0.0 && t_large >= 0.0); - ASSERT_EQ(ty_large - ty_small, 15000); - if (t_small > 0.5) { - double ratio = t_large / t_small; - printf(" cross registry ratio 16000/1000: %.1fx (linear ~16x)\n", ratio); - ASSERT(ratio < 48.0); - } - PASS(); -} - SUITE(py_lsp_scale) { RUN_TEST(pylsp_scale_linear_growth); - RUN_TEST(pylsp_scale_cross_registry_build_linear); } From c21c42b4d6ce88da41fe040d8a6254405bdfbeff Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:30:41 +0200 Subject: [PATCH 10/16] test(cli): create the non-ASCII Windows profile through the wide API The Windows regression test for #2145 created its profile directory with test_mkdirp and read the result as a boolean. test_mkdirp returns 0 on success, so the test failed as soon as the directory existed and never reached the detection it was written to check. Inverting the check is not enough on its own. test_mkdirp uses cbm_mkdir, which is the narrow _mkdir on Windows. Under an ANSI code page such as 1252 it does not create the intended name: it creates a directory whose name is the UTF-8 bytes read as code-page characters. The old narrow stat finds that mangled directory and the new wide lookup correctly does not, so the test would pass without the fix and fail with it. th_mkdir_p goes through the wide API and creates the real name, the way a non-ASCII Windows profile actually looks. Since 2b329fe7, Claude Code is detected from settings.json, .claude.json or the claude CLI, through cbm_file_exists, which is already wide on Windows; a bare .claude directory no longer marks it as detected. The test now asserts Cursor, which is detected by its directory alone, so the assertion goes through dir_exists, the function this branch changes. Signed-off-by: Martin Vogel --- tests/test_cli.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_cli.c b/tests/test_cli.c index 42fba903e..c72095f22 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14597,19 +14597,24 @@ TEST(cli_detect_agents_finds_zed_in_roaming_appdata) { #endif #ifdef _WIN32 +/* #2145: a profile path outside the ANSI code page must stay visible to agent + * detection. The directory is created with th_mkdir_p, which goes through the + * wide-char API: test_mkdirp uses cbm_mkdir, the narrow _mkdir on Windows, + * which would create a code-page-mangled name instead of this one. Cursor is + * detected by its directory alone, so the assertion goes through dir_exists. */ TEST(cli_detect_agents_handles_non_ascii_windows_profile) { char home[1024]; snprintf(home, sizeof(home), "%s/cbm-用户-profile", cbm_tmpdir()); test_rmdir_r(home); - char claude_dir[1024]; - snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", home); - if (!test_mkdirp(claude_dir)) + char cursor_dir[1024]; + snprintf(cursor_dir, sizeof(cursor_dir), "%s/.cursor", home); + if (th_mkdir_p(cursor_dir) != 0) FAIL("could not create non-ASCII Windows profile path"); cbm_detected_agents_t agents = cbm_detect_agents(home); test_rmdir_r(home); - if (!agents.claude_code) - FAIL("Claude detection must support non-ASCII Windows profile paths"); + if (!agents.cursor) + FAIL("agent detection must support non-ASCII Windows profile paths"); PASS(); } #endif From 4c09f7c70cf083bd213921772de954c24652049c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 01:43:14 +0200 Subject: [PATCH 11/16] feat(plugin): Claude Code plugin marketplace, discovery slice Make this repository a Claude Code plugin marketplace so users can find and add the server from inside Claude Code: claude plugin marketplace add DeusData/codebase-memory-mcp claude plugin install codebase-memory@codebase-memory This is slice 1 of #1188: discovery only. The plugin registers the MCP server and nothing else; the skill, the graph agents and the context hooks still come from `codebase-memory-mcp install`. - .claude-plugin/marketplace.json: one plugin, sourced from ./plugin. - plugin/.claude-plugin/plugin.json: version pinned to the current release. Without a pinned version every commit to main would be a new plugin version for installed users. - plugin/.mcp.json: stdio server `npx -y codebase-memory-mcp@0.12.1`. The pin keeps main from advertising a server version npm does not have; the npm package downloads the release binary on first start and verifies it against the release checksums. - .gitignore: the root .mcp.json stays ignored; plugin/.mcp.json is published. - README: install section, and "use one or the other": with both the plugin and `install`, Claude Code runs the server twice under two tool namespaces. - tests/test_version_metadata_contract.sh: both plugin files are release-tracking surfaces; a new claim form reads the npm package spec (`codebase-memory-mcp@X.Y.Z`), and the unregistered-surface sweep now covers plugin/ and .claude-plugin/. The post-release sync that bumps server.json must bump these pins too, or this contract goes red. No language count is stated anywhere in the plugin metadata, so the language-count contract has nothing new to track. Refs #1188 Co-authored-by: Kody Signed-off-by: Martin Vogel --- .claude-plugin/marketplace.json | 15 +++++++++++++++ .gitignore | 2 ++ README.md | 24 ++++++++++++++++++++++++ plugin/.claude-plugin/plugin.json | 9 +++++++++ plugin/.mcp.json | 9 +++++++++ tests/test_version_metadata_contract.sh | 16 +++++++++++++--- 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugin/.claude-plugin/plugin.json create mode 100644 plugin/.mcp.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..f44b5f3c2 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "codebase-memory", + "description": "The codebase-memory-mcp code knowledge graph server, as a Claude Code plugin.", + "owner": { + "name": "DeusData", + "url": "https://github.com/DeusData" + }, + "plugins": [ + { + "name": "codebase-memory", + "source": "./plugin", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens." + } + ] +} diff --git a/.gitignore b/.gitignore index 1533c93e0..c388fa360 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ Thumbs.db # MCP config (user-local, generated by install command) .mcp.json +# ...except the Claude Code plugin's server definition, which is published +!plugin/.mcp.json # MCP Registry auth tokens .mcpregistry_* diff --git a/README.md b/README.md index 7cdac83bf..8d581a0ac 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,30 @@ Working in a clone? Use `.` in place of the flake URL, e.g. `nix run .#codebase- You: "Install this MCP server: https://github.com/DeusData/codebase-memory-mcp" ``` +### Install via Claude Code plugin + +This repository is also a Claude Code plugin marketplace, so you can find and +add the server from inside Claude Code: + +```bash +claude plugin marketplace add DeusData/codebase-memory-mcp +claude plugin install codebase-memory@codebase-memory +``` + +The plugin registers the MCP server only. It starts the server with +`npx -y codebase-memory-mcp@`, pinned to the current release; the first +start downloads that release's binary through the npm package, which verifies +it against the release checksums. The skill, the three graph agents and the +context hooks are not part of the plugin: they come from +`codebase-memory-mcp install`. + +Use one or the other. `install` registers the same server for Claude Code, so +with both you run it twice, under two tool namespaces +(`mcp__codebase-memory-mcp__*` and +`mcp__plugin_codebase-memory_codebase-memory-mcp__*`). Choose `install` for the +full Claude Code setup; choose the plugin to add just the server without +running an installer. + ### Build from Source
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..0960a2b71 --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "codebase-memory", + "version": "0.12.1", + "description": "Codebase knowledge graph for AI agents: sub-ms queries, 99% fewer tokens.", + "author": { + "name": "DeusData", + "url": "https://github.com/DeusData" + } +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 000000000..07fbe13c1 --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "npx", + "args": ["-y", "codebase-memory-mcp@0.12.1"] + } + } +} diff --git a/tests/test_version_metadata_contract.sh b/tests/test_version_metadata_contract.sh index b74203bf6..54d787944 100644 --- a/tests/test_version_metadata_contract.sh +++ b/tests/test_version_metadata_contract.sh @@ -109,6 +109,8 @@ SURFACES=( "pkg/chocolatey/tools/chocolateyInstall.ps1|release" "pkg/scoop/codebase-memory-mcp.json|release" "pkg/homebrew/Formula/codebase-memory-mcp.rb|release" + "plugin/.claude-plugin/plugin.json|release" + "plugin/.mcp.json|release" "pkg/aur/PKGBUILD|pin:0.8.1" "pkg/aur/.SRCINFO|pin:0.8.1" ) @@ -163,6 +165,12 @@ versions_in() { # cannot see it, because the version keyword is in the function NAME. grep -oE 'return "[0-9]+\.[0-9]+\.[0-9]+"' "$path" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' + # npm package-spec form: the Claude Code plugin launches the server as + # `npx -y codebase-memory-mcp@X.Y.Z`, and that pin is the version every + # plugin user actually runs. No declaration keyword sits next to it, so + # without this form plugin/.mcp.json reads as a surface with no claim. + grep -oE 'codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' "$path" | + grep -oE '[0-9]+\.[0-9]+\.[0-9]+$' } | sort -u } @@ -280,7 +288,9 @@ fi # this contract exists to prevent, and every check above would still pass. The # sweep pattern is deliberately BROADER than the extractors in section 3: if the # two shared a regex, a claim form the extractors cannot see would be invisible -# to the sweep as well. +# to the sweep as well. The Claude Code plugin tree (plugin/, .claude-plugin/) is +# swept too: a later plugin file that pins `codebase-memory-mcp@X.Y.Z` (a hook +# command, say) must be registered here, or it would go stale unchecked. while IFS= read -r path; do [[ -z "$path" ]] && continue for entry in "${SURFACES[@]}"; do @@ -293,8 +303,8 @@ while IFS= read -r path; do "SURFACES so it stays in step with the release, or to EXEMPT with a" \ "note saying what its number actually is." done < <( - git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"' \ - -- server.json pkg tests/test_version_metadata_contract.sh 2>/dev/null || true + git grep -IlE '(version|pkgver|releases/(download|tag)/v)[^0-9]{0,16}[0-9]+\.[0-9]+\.[0-9]+|return "[0-9]+\.[0-9]+\.[0-9]+"|codebase-memory-mcp@[0-9]+\.[0-9]+\.[0-9]+' \ + -- server.json pkg plugin .claude-plugin tests/test_version_metadata_contract.sh 2>/dev/null || true ) if ((failures > 0)); then From ee6e78b7142d51f849acaaa34cf010492568a28c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 23:22:14 +0200 Subject: [PATCH 12/16] fix(java): ignore comment extras in record accessor parameters Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 14 +++++- tests/test_call_reference_contract.c | 70 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b88d77928..2525f0a15 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9590,7 +9590,19 @@ static bool record_declares_accessor(CBMExtractCtx *ctx, TSNode node, const char } /* Same name, no parameters — that is the accessor. */ TSNode params = ts_node_child_by_field_name(member, TS_FIELD("parameters")); - if (ts_node_is_null(params) || ts_node_named_child_count(params) == 0) { + if (ts_node_is_null(params)) { + return true; + } + /* Comments are named extras, not parameters. */ + bool has_parameters = false; + uint32_t pc = ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pc; pi++) { + if (!ts_node_is_extra(ts_node_named_child(params, pi))) { + has_parameters = true; + break; + } + } + if (!has_parameters) { return true; } } diff --git a/tests/test_call_reference_contract.c b/tests/test_call_reference_contract.c index 67e30132a..ec5ce4abe 100644 --- a/tests/test_call_reference_contract.c +++ b/tests/test_call_reference_contract.c @@ -578,6 +578,74 @@ TEST(call_java_record_varargs_accessor_has_array_return) { PASS(); } +/* Comment extras in an otherwise empty parameter list must not create a + * second, implicit accessor at the record component's source location. */ +TEST(call_java_record_commented_accessor_has_only_explicit_span) { + static const char *sources[] = {"record Value(int x) {\n" + " public int x(/* explanation */) {\n" + " return x;\n" + " }\n" + "}\n", + "record Value(int x) {\n" + " public int x(// explanation\n" + " ) {\n" + " return x;\n" + " }\n" + "}\n"}; + static const int end_lines[] = {4, 5}; + for (int fixture = 0; fixture < 2; fixture++) { + CBMFileResult *result = + cbm_extract_file(sources[fixture], (int)strlen(sources[fixture]), CBM_LANG_JAVA, + "repro", "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int accessors = 0; + int explicit_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + accessors++; + if (def->start_line == 2 && def->end_line == end_lines[fixture]) { + explicit_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(accessors, 1); + ASSERT_EQ(explicit_spans, 1); + } + PASS(); +} + +TEST(call_java_record_commented_overload_keeps_implicit_accessor) { + static const char source[] = + "record Value(int x) {\n" + " public int x(/* explanation */ int scale) { return x * scale; }\n" + "}\n"; + CBMFileResult *result = cbm_extract_file(source, (int)strlen(source), CBM_LANG_JAVA, "repro", + "Value.java", 0, NULL, NULL); + ASSERT_NOT_NULL(result); + int methods = 0; + int implicit_spans = 0; + int overload_spans = 0; + for (int i = 0; i < result->defs.count; i++) { + CBMDefinition *def = &result->defs.items[i]; + if (strcmp(def->name, "x") == 0 && strcmp(def->label, "Method") == 0) { + methods++; + if (def->start_line == 1 && def->end_line == 1) { + implicit_spans++; + } + if (def->start_line == 2 && def->end_line == 2) { + overload_spans++; + } + } + } + cbm_free_result(result); + ASSERT_EQ(methods, 2); + ASSERT_EQ(implicit_spans, 1); + ASSERT_EQ(overload_spans, 1); + PASS(); +} + SUITE(call_reference_contract) { RUN_TEST(call_reference_typescript_direct_argument_is_exact); RUN_TEST(call_reference_kotlin_alias_argument_is_exact); @@ -596,6 +664,8 @@ SUITE(call_reference_contract) { RUN_TEST(call_reference_go_ambiguous_promoted_method_stays_usage); RUN_TEST(call_java_record_accessor_binds_the_record); RUN_TEST(call_java_record_varargs_accessor_has_array_return); + RUN_TEST(call_java_record_commented_accessor_has_only_explicit_span); + RUN_TEST(call_java_record_commented_overload_keeps_implicit_accessor); RUN_TEST(call_java_static_import_does_not_call_field_sequential); RUN_TEST(call_java_static_import_does_not_call_field_parallel); } From 729ce339bd130e44d317d7ecf5c296bdfe016350 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 26 Oct 2026 02:56:12 +0100 Subject: [PATCH 13/16] fix(java): register generated record accessors by qualified name only A record's implicit accessors are members the compiler writes and the source never spells. Their nodes now carry the property generated=record, and every registry registration path files them in the exact (qualified-name) map only, never in the short-name index. The Java type-aware resolver still binds p.y() on a Point to Point.y; a call such as v.y() on a type from outside the project no longer binds the accessor through a unique_name / suffix_match / field_type_hint / fuzzy guess. - extract_defs.c: each implicit accessor carries extra_props generated=record (key CBM_PROP_GENERATED, cbm.h). - registry.c: cbm_registry_add_qn_only, and the pure predicates cbm_def_props_generated / cbm_node_props_generated. - pass_definitions.c, pass_parallel.c, pipeline_incremental.c: the three registration sites route a generated member to the exact map only. - pipeline_delta.c: the incremental preseed proxy keeps the mark, so an incremental re-index registers the member exactly as a full one does (the proxy carried only a few properties and the accessor came back into the short-name index). Tests: pipeline_java_record_accessors_generated_qn_only (sequential, parallel and incremental venues) and two registry unit tests. Signed-off-by: Martin Vogel --- internal/cbm/cbm.h | 14 +- internal/cbm/extract_defs.c | 10 ++ src/pipeline/pass_definitions.c | 11 +- src/pipeline/pass_parallel.c | 11 +- src/pipeline/pipeline.h | 18 +++ src/pipeline/pipeline_delta.c | 54 ++++++- src/pipeline/pipeline_incremental.c | 6 + src/pipeline/registry.c | 26 +++ tests/test_pipeline.c | 236 ++++++++++++++++++++++++++++ tests/test_registry.c | 47 ++++++ 10 files changed, 419 insertions(+), 14 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index ade41fea5..ef5fad51f 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -298,9 +298,10 @@ typedef struct { * are bound to the owning result's source identity before cross-file use. */ uint32_t test_name_start_byte, test_name_end_byte; uint32_t test_body_start_byte, test_body_end_byte; - /* Documents only: facts a document definition adds to its node's - * properties, as a NULL-terminated key, value, key, value, ... list (an - * ADR's id, status, date, deciders, title). Keys are fixed literals; the + /* Facts a definition adds to its node's properties, as a NULL-terminated + * key, value, key, value, ... list: a document's (an ADR's id, status, + * date, deciders, title), or CBM_PROP_GENERATED on a member the compiler + * writes and the source never spells. Keys are fixed literals; the * values are escaped where the properties are written. Tail field: * zero-init stays valid. */ const char **extra_props; @@ -315,6 +316,13 @@ typedef struct { const char *variant_key; } CBMDefinition; +/* extra_props key of a generated member, one the compiler writes and the + * source never spells: a Java record's implicit accessor carries + * `generated: record`. The node keeps the property, and the pipeline registers + * such a definition by qualified name only, so no short-name guess can land on + * it (pipeline.h cbm_def_props_generated). */ +#define CBM_PROP_GENERATED "generated" + /* Argument captured from a call expression */ typedef struct { const char *expr; // raw expression text ("payload.info", "MY_URL", "'hello'") diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2525f0a15..65829e9b8 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -9668,6 +9668,16 @@ static void extract_record_accessors(CBMExtractCtx *ctx, TSNode node, const char const char *type = cbm_node_text(a, ctype, ctx->source); adef.return_type = spread && type ? cbm_arena_sprintf(a, "%s[]", type) : type; } + /* The compiler writes this member and the source never spells it: its + * node says so, and the pipeline registers it by qualified name only, + * so no short-name guess can land on it. */ + const char **kv = (const char **)cbm_arena_alloc(a, CBM_SZ_3 * sizeof(char *)); + if (kv) { + kv[0] = CBM_PROP_GENERATED; + kv[SKIP_ONE] = "record"; + kv[PAIR_LEN] = NULL; + } + adef.extra_props = kv; cbm_defs_push(&ctx->result->defs, a, adef); } } diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index ed60d3f9a..45bb5d3a7 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -509,10 +509,15 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (helpers.c): callables + type-like containers (INHERITS/IMPLEMENTS/method/ * field resolution), Variable/Field (READS/WRITES resolution), and Table/View * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed - * through the same predicate, so the three registries cannot diverge. */ + * through the same predicate, so the three registries cannot diverge. + * A generated member is registered by qualified name only, at all three. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } } char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 23f4934ce..dd04e3ad9 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1811,10 +1811,15 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d return 0; } /* Registry membership is defined ONCE by cbm_label_is_registry_symbol - * (helpers.c) — see pass_definitions.c for the per-label rationale. */ + * (helpers.c) — see pass_definitions.c for the per-label rationale, and + * for the generated member registered by qualified name only. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, def->label, - lang, module_prefix_len); + if (cbm_def_props_generated(def->extra_props)) { + cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); + } else { + cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, + def->label, lang, module_prefix_len); + } (*reg_entries)++; } char marked[CBM_SZ_4K]; diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index fca310004..b564b4575 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -277,6 +277,24 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, CBMLanguage lang, size_t module_prefix_len); +/* Register a symbol for exact (qualified-name) lookups only: it is never + * offered to the short-name strategies (unique_name, suffix_match, + * field_type_hint, fuzzy). For a generated member (cbm_def_props_generated), + * which the source never spells, so only a type-aware resolver that knows its + * owner may bind it. The three graph registration sites route such a symbol + * here instead of cbm_registry_add_lang_in_module. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label); + +/* True when a definition's extra_props (key, value, ..., NULL) carry the + * generated-member key (cbm.h CBM_PROP_GENERATED), e.g. a Java record's + * implicit accessor. Pure; unit-tested in test_registry.c. */ +bool cbm_def_props_generated(const char *const *extra_props); + +/* The stored-node twin of cbm_def_props_generated, for the incremental + * registry seed: true when a node's properties JSON carries that key as a + * string property. Pure; unit-tested in test_registry.c. */ +bool cbm_node_props_generated(const char *properties_json); + /* True when code in `caller` can call a symbol defined in `target` by bare * name: same language, same interop family (JS/TS + script hosts, C/C++/CUDA/ * ObjC, JVM, .NET, ...; the table lives in registry.c), or Swift with the C diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 895cded93..ca1dbab46 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -590,6 +590,39 @@ static char *delta_owner_props(const char *project, const char *name, const char return out; } +/* `props` (a JSON object) with a generated member's mark (cbm.h + * CBM_PROP_GENERATED) added, or NULL when `generated` is NULL/empty + * (CBM_MEM_CLASS_OTHER; the caller frees). */ +static char *delta_generated_props(const char *props, const char *generated) { + if (!generated || !generated[0]) { + return NULL; + } + size_t pl = props ? strlen(props) : 0; + bool has_members = pl > PAIR_LEN && props[0] == '{' && props[pl - SKIP_ONE] == '}'; + size_t cap = pl + PAIR_LEN * strlen(generated) + CBM_SZ_32; + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!out) { + return NULL; + } + size_t k = 0; + if (has_members) { + memcpy(out, props, pl - SKIP_ONE); + k = pl - SKIP_ONE; + out[k++] = ','; + } else { + out[k++] = '{'; + } + k += (size_t)snprintf(out + k, cap - k, "\"%s\":\"", CBM_PROP_GENERATED); + for (const char *p = generated; *p && k + CBM_SZ_3 < cap; p++) { + if (*p == '"' || *p == '\\') { + out[k++] = '\\'; + } + out[k++] = *p; + } + snprintf(out + k, cap - k, "\"}"); + return out; +} + int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf) { sqlite3 *db = cbm_store_get_db(store); if (!db) { @@ -634,7 +667,12 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * goes by -- a Go method's QN has no receiver, a class-body variable's QN * no class. A document naming `Order.Reset` binds such a node through * its owner (doc_links_pdf.c), so a re-resolved document needs it too. - * Only where owner + name differs from the QN (delta_owner_props). */ + * Only where owner + name differs from the QN (delta_owner_props). + * + * And a generated member's mark (a Java record's implicit accessor, a + * Method node): the registry seed reads it to register the member by + * qualified name only, as the full build does (cbm_node_props_generated); + * without it the proxy came back into the short-name index. */ sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2( db, @@ -646,7 +684,10 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g " start_line, end_line," " CASE WHEN label IN ('Method','Variable')" " AND instr(properties, '\"parent_class\"') > 0" - " THEN json_extract(properties, '$.parent_class') END" + " THEN json_extract(properties, '$.parent_class') END," + " CASE WHEN label = 'Method'" + " AND instr(properties, '\"" CBM_PROP_GENERATED "\"') > 0" + " THEN json_extract(properties, '$." CBM_PROP_GENERATED "') END" " FROM nodes" " WHERE project = ?1 AND label NOT IN" " ('Macro','Comment','Section','Branch','Commit','Tag')" @@ -670,13 +711,16 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g int start_line = sqlite3_column_int(stmt, 6); int end_line = sqlite3_column_int(stmt, 7); const char *parent = (const char *)sqlite3_column_text(stmt, 8); + const char *generated = (const char *)sqlite3_column_text(stmt, 9); char *owner_props = parent ? delta_owner_props(project, name, qn, parent, client_props) : NULL; + const char *props = owner_props ? owner_props : (client_props ? client_props : "{}"); + char *generated_props = delta_generated_props(props, generated); /* Pin the gbuf id to the database id: proxies ARE their rows. */ cbm_gbuf_set_next_id(gbuf, id); - int64_t got = - cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, end_line, - owner_props ? owner_props : (client_props ? client_props : "{}")); + int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", start_line, + end_line, generated_props ? generated_props : props); + cbm_free(CBM_MEM_CLASS_OTHER, generated_props); cbm_free(CBM_MEM_CLASS_OTHER, owner_props); if (got != id) { /* A QN collision inside the preseed set would silently split diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 6e6740573..3d44cc696 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1348,6 +1348,12 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } + /* A generated member stays out of the short-name index here as in the + * full index (pass_definitions.c); its node carries the mark. */ + if (cbm_node_props_generated(node->properties_json)) { + cbm_registry_add_qn_only(seed->registry, node->qualified_name, node->label); + return; + } CBMLanguage lang = cbm_registry_synthetic_path_language(node->file_path); if (lang == CBM_LANG_COUNT && seed->lang_by_path && node->file_path) { void *v = cbm_ht_get(seed->lang_by_path, node->file_path); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 209691ea2..51beb1eaf 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1406,6 +1406,32 @@ void cbm_registry_add_lang_in_module(cbm_registry_t *r, const char *name, } } +/* A generated member (a Java record's implicit accessor) is a definition the + * source never spells: no call names it except through its owner's type. The + * exact map holds it for the LSP targets and the import/same-module lookups, + * as it holds a Python function's locals; the by-name index does not, so no + * unique_name / suffix_match / field_type_hint / fuzzy guess can land on it. */ +void cbm_registry_add_qn_only(cbm_registry_t *r, const char *qualified_name, const char *label) { + if (!r || !qualified_name || !label) { + return; + } + (void)registry_add_exact(r, qualified_name, label); +} + +bool cbm_def_props_generated(const char *const *extra_props) { + for (const char *const *kv = extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { + if (strcmp(kv[0], CBM_PROP_GENERATED) == 0) { + return true; + } + } + return false; +} + +bool cbm_node_props_generated(const char *properties_json) { + /* The key as the definition passes write it: "generated":"". */ + return properties_json && strstr(properties_json, "\"" CBM_PROP_GENERATED "\":\"") != NULL; +} + /* ── Lookup ──────────────────────────────────────────────────────── */ bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index bd76775f3..6dbbdad39 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17366,6 +17366,239 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { PASS(); } +/* A Java record's implicit accessors are GENERATED members (#2295): Method + * nodes marked `generated: record`, registered by qualified name only, as a + * Python function's locals are. The type-aware resolver binds `p.y()` on a + * Point to Point.y; no short-name guess may land on a member the source never + * spells. `far` calls y() on a type outside the project, so the project-wide + * name registry is its only route to Point.y. `data` calls x(), whose other + * spelling is Spring's private field: neither the accessor (a name guess) nor + * the field (a Java call never names data) is its target. The record's + * hand-written z() and norm() are source members: no `generated` mark. */ +static const char jgen_point_java[] = + "package com.example;\n" + "public record Point(double x, double y, double z) {\n" + " public double z() { return z; }\n" + " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" + "}\n"; +static const char jgen_spring_java[] = "package com.example;\n" + "public final class Spring {\n" + " private double x;\n" + " public void put(double v) { this.x = v; }\n" + "}\n"; +static const char jgen_caller_java[] = "package com.example;\n" + "import org.external.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + "}\n"; + +/* The id of the one `label` node called `name` in `file_path`; 0 when there is + * none or more than one. `generated` (optional) receives whether its + * properties carry the `generated: record` mark. */ +static int64_t jgen_node(cbm_store_t *s, const char *project, const char *name, const char *label, + const char *file_path, bool *generated) { + cbm_node_t *nodes = NULL; + int count = 0; + int64_t id = 0; + int hits = 0; + bool marked = false; + if (cbm_store_find_nodes_by_name(s, project, name, &nodes, &count) != CBM_STORE_OK) { + return 0; + } + for (int i = 0; i < count; i++) { + if (nodes[i].label && strcmp(nodes[i].label, label) == 0 && nodes[i].file_path && + strcmp(nodes[i].file_path, file_path) == 0) { + hits++; + id = nodes[i].id; + marked = nodes[i].properties_json && + strstr(nodes[i].properties_json, "\"generated\":\"record\"") != NULL; + } + } + cbm_store_free_nodes(nodes, count); + if (generated) { + *generated = marked; + } + return hits == 1 ? id : 0; +} + +/* CALLS edges out of `source_id` into `target_id`, or (target_id 0) into any + * node called `target_name`. */ +static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, + const char *target_name) { + cbm_edge_t *edges = NULL; + int ec = 0; + int n = 0; + if (cbm_store_find_edges_by_source_type(s, source_id, "CALLS", &edges, &ec) != CBM_STORE_OK) { + return -1; + } + for (int i = 0; i < ec; i++) { + if (target_id) { + n += edges[i].target_id == target_id; + continue; + } + cbm_node_t t = {0}; + if (cbm_store_find_node_by_id(s, edges[i].target_id, &t) == CBM_STORE_OK && t.name && + strcmp(t.name, target_name) == 0) { + n++; + } + cbm_node_free_fields(&t); + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return n; +} + +/* One fact of jgen_check: reported, counted, and the check goes on, so a run + * shows every fact that does not hold. */ +#define JGEN_EXPECT(cond) \ + do { \ + if (!(cond)) { \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failed++; \ + } \ + } while (0) + +/* Every fact the fixture pins, on one indexed store; the number that fail. */ +static int jgen_check(cbm_store_t *s, const char *project) { + int failed = 0; + bool gen_x = false; + bool gen_y = false; + bool gen_z = true; + bool gen_norm = true; + int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); + int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); + int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); + int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); + int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0); + /* The compiler's members are marked; the hand-written ones are not. */ + JGEN_EXPECT(gen_x); + JGEN_EXPECT(gen_y); + JGEN_EXPECT(!gen_z); + JGEN_EXPECT(!gen_norm); + /* Bound by qualified name through the receiver's type ... */ + JGEN_EXPECT(jgen_calls(s, near, acc_y, NULL) == 1); + /* ... and never by a short-name guess. */ + JGEN_EXPECT(jgen_calls(s, far, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); + /* Neither the accessor nor Spring's field `x`. */ + JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + return failed; +} +#undef JGEN_EXPECT + +static int jgen_write_fixture(const char *tmp) { + write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Spring.java", jgen_spring_java); + write_temp_file(tmp, "Caller.java", jgen_caller_java); + return 0; +} + +TEST(pipeline_java_record_accessors_generated_qn_only) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* The same fixture through pass_parallel.c: >= 50 files and CBM_WORKERS=4. */ +TEST(pipeline_java_record_accessors_generated_qn_only_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "Filler%d.java", i); + snprintf(body, sizeof(body), + "package com.example;\nclass Filler%d { int f() { return %d; } }\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run = p ? cbm_pipeline_run(p) : -1; + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + ASSERT_NOT_NULL(p); + ASSERT_EQ(run, 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p)); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + +/* An incremental re-index seeds its registry from the stored graph: the + * untouched Point.java's accessors come back from their nodes, and must stay + * out of the short-name index exactly as in the full index while the touched + * Caller.java is re-resolved. */ +TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_java_gen_incr_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + jgen_write_fixture(tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/java_gen_incr.db", tmp); + cbm_pipeline_t *p1 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p1); + ASSERT_EQ(cbm_pipeline_run(p1), 0); + cbm_pipeline_free(p1); + + char caller_path[512]; + snprintf(caller_path, sizeof(caller_path), "%s/Caller.java", tmp); + ASSERT_EQ(th_append_file(caller_path, "\n// touched: re-resolve Caller.java incrementally\n"), + 0); + + cbm_pipeline_t *p2 = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p2); + ASSERT_EQ(cbm_pipeline_run(p2), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int rc = jgen_check(s, cbm_pipeline_project_name(p2)); + cbm_store_close(s); + cbm_pipeline_free(p2); + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + PASS(); +} + /* As pyn_edge, and the edge's properties also contain `fragment`. */ static bool pyn_edge_with(cbm_store_t *s, const char *project, const char *src_rel, const char *tgt_rel, const char *type, const char *fragment) { @@ -27777,6 +28010,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_import_never_binds_a_local_def_issue1548); RUN_TEST(pipeline_python_nested_defs_parallel_issue1548); RUN_TEST(pipeline_python_nested_def_local_after_incremental_issue1548); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_parallel); + RUN_TEST(pipeline_java_record_accessors_generated_qn_only_after_incremental); RUN_TEST(pipeline_python_local_def_shadows_module_def_issue1548); RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); diff --git a/tests/test_registry.c b/tests/test_registry.c index d7e062bfc..7eb012679 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1436,6 +1436,51 @@ TEST(java_call_never_binds_data_member) { PASS(); } +TEST(generated_member_registers_by_qn_only) { + /* A generated member (a Java record's implicit accessor) is found by its + * qualified name, never by a short-name guess. The same member registered + * the ordinary way is exactly what the guess binds. */ + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.y", "Method"); + ASSERT_TRUE(cbm_registry_exists(r, "proj.geo.Point.Point.y")); + ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.geo.Point.Point.y"), "Method"); + const char **qns = NULL; + int n = -1; + ASSERT_EQ(cbm_registry_find_by_name(r, "y", &qns, &n), 0); + ASSERT_EQ(n, 0); + cbm_resolution_t res = cbm_registry_resolve(r, "v.y", "proj.app.Caller", NULL, NULL, 0); + ASSERT_NULL(res.qualified_name); + cbm_registry_add(r, "x", "proj.geo.Point.Point.x", "Method"); + res = cbm_registry_resolve(r, "v.x", "proj.app.Caller", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.geo.Point.Point.x"); + /* Degenerate input registers nothing. */ + cbm_registry_add_qn_only(r, NULL, "Method"); + cbm_registry_add_qn_only(r, "proj.geo.Point.Point.z", NULL); + cbm_registry_add_qn_only(NULL, "proj.geo.Point.Point.z", "Method"); + ASSERT_EQ(cbm_registry_size(r), 2); + cbm_registry_free(r); + PASS(); +} + +TEST(generated_member_mark_on_defs_and_nodes) { + static const char *const record[] = {"generated", "record", NULL}; + static const char *const later[] = {"source", "openapi", "generated", "lombok", NULL}; + static const char *const doc[] = {"id", "ADR-7", "status", "accepted", NULL}; + static const char *const keyless[] = {"generated", NULL}; + ASSERT_TRUE(cbm_def_props_generated(record)); + ASSERT_TRUE(cbm_def_props_generated(later)); + ASSERT_FALSE(cbm_def_props_generated(doc)); + ASSERT_FALSE(cbm_def_props_generated(keyless)); /* a key without a value is no fact */ + ASSERT_FALSE(cbm_def_props_generated(NULL)); + ASSERT_TRUE( + cbm_node_props_generated("{\"parent_class\":\"p.Point\",\"generated\":\"record\"}")); + ASSERT_FALSE(cbm_node_props_generated("{\"parent_class\":\"p.Point\"}")); + /* The word inside a value is not the key. */ + ASSERT_FALSE(cbm_node_props_generated("{\"docstring\":\"code generated by protoc\"}")); + ASSERT_FALSE(cbm_node_props_generated(NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -2298,6 +2343,8 @@ SUITE(registry) { RUN_TEST(c_member_access_binds_by_owner_only); RUN_TEST(c_member_rule_covers_c_files_and_every_dot_h); RUN_TEST(java_call_never_binds_data_member); + RUN_TEST(generated_member_registers_by_qn_only); + RUN_TEST(generated_member_mark_on_defs_and_nodes); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); RUN_TEST(python_builtin_member_table_matches_builtin_type_methods); From 6c52e8260cc01e7cb5470c16b106b28815c426de Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:12:42 +0100 Subject: [PATCH 14/16] fix(java): no import resolves to a generated record accessor The import resolver's symbol-name fallback tries the trailing segments of an import path that names no project symbol, and a package segment that merely spells a record accessor bound the import to it. On elasticsearch, `import javax.crypto.spec.PBEKeySpec` bound a record's spec() accessor, and `new PBEKeySpec(...)`, annotations and type uses then followed the import onto it: 6,302 IMPORTS edges into accessors, plus CALLS, USAGE, DECORATES and THROWS through the import map. A generated member is registered by qualified name only, and no Java import can name an instance accessor, so the fallback now skips it exactly as it skips a Python def-local (#1548). The record pipeline fixture gains an import whose package segment spells an accessor. Without the change it fails in the sequential, parallel and incremental venues: an IMPORTS edge into Point.y, and make() -> Point.y. Signed-off-by: Martin Vogel --- src/pipeline/pass_pkgmap.c | 9 +++++++++ tests/test_pipeline.c | 29 ++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index ef3ba0f10..128ba5fc3 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2592,6 +2592,15 @@ static const cbm_gbuf_node_t *resolve_import_strategies( if (import_candidate_is_python_local(ctx->gbuf, cand)) { continue; } + /* Nor is a generated member (a Java record's implicit + * accessor, an instance method no import can name). It is + * registered by qualified name only, and a package segment + * that merely spells it (`javax.crypto.spec` -> spec()) + * must not bind the import -- or every use of the + * imported name -- to it. */ + if (cbm_node_props_generated(cand->properties_json)) { + continue; + } if (source_file_qn && cand->qualified_name && strcmp(cand->qualified_name, source_file_qn) == 0) { continue; /* self */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6dbbdad39..940169b77 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17374,7 +17374,12 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { * name registry is its only route to Point.y. `data` calls x(), whose other * spelling is Spring's private field: neither the accessor (a name guess) nor * the field (a Java call never names data) is its target. The record's - * hand-written z() and norm() are source members: no `generated` mark. */ + * hand-written z() and norm() are source members: no `generated` mark. + * `org.external.y.Vector` is not in the project, and its package segment `y` + * spells the accessor: the import's symbol-name fallback must not bind it + * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import + * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` + * bound a record's `spec()`). */ static const char jgen_point_java[] = "package com.example;\n" "public record Point(double x, double y, double z) {\n" @@ -17387,11 +17392,12 @@ static const char jgen_spring_java[] = "package com.example;\n" " public void put(double v) { this.x = v; }\n" "}\n"; static const char jgen_caller_java[] = "package com.example;\n" - "import org.external.Vector;\n" + "import org.external.y.Vector;\n" "public final class Caller {\n" " public double near(Point p) { return p.y(); }\n" " public double far(Vector v) { return v.y(); }\n" " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" "}\n"; /* The id of the one `label` node called `name` in `file_path`; 0 when there is @@ -17451,6 +17457,19 @@ static int jgen_calls(cbm_store_t *s, int64_t source_id, int64_t target_id, return n; } +/* Edges of `type` into `target_id`. */ +static int jgen_edges_into(cbm_store_t *s, int64_t target_id, const char *type) { + cbm_edge_t *edges = NULL; + int ec = 0; + if (cbm_store_find_edges_by_target_type(s, target_id, type, &edges, &ec) != CBM_STORE_OK) { + return -1; + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + return ec; +} + /* One fact of jgen_check: reported, counted, and the check goes on, so a run * shows every fact that does not hold. */ #define JGEN_EXPECT(cond) \ @@ -17475,8 +17494,9 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); + int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); @@ -17489,6 +17509,9 @@ static int jgen_check(cbm_store_t *s, const char *project) { JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); /* Neither the accessor nor Spring's field `x`. */ JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); + /* No import names a generated member, so nothing follows one onto it. */ + JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); + JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); return failed; } #undef JGEN_EXPECT From 679d1e1af432b0bb1c0873c60668d46ed6736172 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 19:45:08 +0100 Subject: [PATCH 15/16] fix(java): a JVM leaf-name fallback never picks a generated accessor When a JVM LSP callee has no exact node and no Class.method tail match, cbm_pipeline_lsp_target_node takes the one project callable with that leaf name. A record accessor became such a callable: on elasticsearch `Objects.nonNull(x)` (37 CALLS) and `filter(Objects::nonNull)` (230 CALL_REFERENCE) bound a test record's nonNull() accessor. A generated member is registered by qualified name only. It still joins through its owner's Class.method tail, but the leaf-name fallback no longer considers it, either as the unique callable or as a rival that makes a same-named project callable ambiguous, so every other bind is decided exactly as before the accessors existed. The record pipeline fixture gains a record Seen(boolean nonNull) with `Objects.nonNull(o)` and `filter(Objects::nonNull)` callers. Without the change it fails in the sequential, parallel and incremental venues. Signed-off-by: Martin Vogel --- src/pipeline/lsp_resolve.h | 16 ++++++++++---- tests/test_pipeline.c | 45 ++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 419f29e26..371cb5536 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -1281,10 +1281,18 @@ static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_target_node_policy( if (strcmp(cand->label, "Function") != 0 && strcmp(cand->label, "Method") != 0) { continue; } - if (!unique_callable) { - unique_callable = cand; - } else if (unique_callable->id != cand->id) { - callable_ambiguous = true; + /* A generated member (a Java record's implicit accessor) is registered + * by qualified name only: it joins through its owner's Class.method + * tail below, never on its leaf name alone -- `Objects.nonNull(x)` is + * no call of some record's nonNull(). Nor does it make a same-named + * callable ambiguous: the leaf fallback sees the graph as if it were + * not there. */ + if (!cbm_node_props_generated(cand->properties_json)) { + if (!unique_callable) { + unique_callable = cand; + } else if (unique_callable->id != cand->id) { + callable_ambiguous = true; + } } if (!cbm_pipeline_qn_class_method_tail_eq(cand->qualified_name, callee_tail)) { continue; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 940169b77..b9bfbb057 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -17379,26 +17379,39 @@ TEST(pipeline_python_nested_def_local_after_incremental_issue1548) { * spells the accessor: the import's symbol-name fallback must not bind it * there (no IMPORTS edge), or `new Vector()` in `make` would follow the import * onto the accessor (elasticsearch: `import javax.crypto.spec.PBEKeySpec` - * bound a record's `spec()`). */ + * bound a record's `spec()`). `stat` calls, and `refs` passes, the JDK's + * Objects.nonNull: no project callable is its target, and Seen's accessor -- + * the only project callable called nonNull -- must not be taken for it on its + * leaf name alone (elasticsearch: `Objects.nonNull(x)` and + * `filter(Objects::nonNull)` bound a test record's nonNull()). */ static const char jgen_point_java[] = "package com.example;\n" "public record Point(double x, double y, double z) {\n" " public double z() { return z; }\n" " public double norm() { return Math.sqrt(x * x + y * y + z * z); }\n" "}\n"; +static const char jgen_seen_java[] = "package com.example;\n" + "record Seen(boolean nonNull) {}\n"; static const char jgen_spring_java[] = "package com.example;\n" "public final class Spring {\n" " private double x;\n" " public void put(double v) { this.x = v; }\n" "}\n"; -static const char jgen_caller_java[] = "package com.example;\n" - "import org.external.y.Vector;\n" - "public final class Caller {\n" - " public double near(Point p) { return p.y(); }\n" - " public double far(Vector v) { return v.y(); }\n" - " public double data(Vector v) { return v.x(); }\n" - " public Object make() { return new Vector(); }\n" - "}\n"; +static const char jgen_caller_java[] = + "package com.example;\n" + "import java.util.List;\n" + "import java.util.Objects;\n" + "import org.external.y.Vector;\n" + "public final class Caller {\n" + " public double near(Point p) { return p.y(); }\n" + " public double far(Vector v) { return v.y(); }\n" + " public double data(Vector v) { return v.x(); }\n" + " public Object make() { return new Vector(); }\n" + " public boolean stat(Object o) { return Objects.nonNull(o); }\n" + " public long refs(List xs) {\n" + " return xs.stream().filter(Objects::nonNull).count();\n" + " }\n" + "}\n"; /* The id of the one `label` node called `name` in `file_path`; 0 when there is * none or more than one. `generated` (optional) receives whether its @@ -17487,6 +17500,8 @@ static int jgen_check(cbm_store_t *s, const char *project) { bool gen_y = false; bool gen_z = true; bool gen_norm = true; + bool gen_nn = false; + int64_t acc_nn = jgen_node(s, project, "nonNull", "Method", "Seen.java", &gen_nn); int64_t acc_x = jgen_node(s, project, "x", "Method", "Point.java", &gen_x); int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); @@ -17495,11 +17510,14 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); - JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0); + int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); + int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); + JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); + JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); + JGEN_EXPECT(gen_nn); JGEN_EXPECT(!gen_z); JGEN_EXPECT(!gen_norm); /* Bound by qualified name through the receiver's type ... */ @@ -17512,12 +17530,17 @@ static int jgen_check(cbm_store_t *s, const char *project) { /* No import names a generated member, so nothing follows one onto it. */ JGEN_EXPECT(jgen_edges_into(s, acc_y, "IMPORTS") == 0); JGEN_EXPECT(jgen_calls(s, make, 0, "y") == 0); + /* The JDK's Objects.nonNull is not Seen's accessor, called or referenced. */ + JGEN_EXPECT(jgen_calls(s, stat, acc_nn, NULL) == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "CALL_REFERENCE") == 0); + JGEN_EXPECT(jgen_edges_into(s, acc_nn, "USAGE") == 0); return failed; } #undef JGEN_EXPECT static int jgen_write_fixture(const char *tmp) { write_temp_file(tmp, "Point.java", jgen_point_java); + write_temp_file(tmp, "Seen.java", jgen_seen_java); write_temp_file(tmp, "Spring.java", jgen_spring_java); write_temp_file(tmp, "Caller.java", jgen_caller_java); return 0; From 8f36ea6c0b15a1060296718e93bb62aa45b5a2d7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 28 Oct 2026 00:49:16 +0100 Subject: [PATCH 16/16] test(pipeline): rename the near/far locals in the record accessor test Windows headers define near and far as empty macros, so the locals named near and far in the generated-accessor pipeline test did not compile on the Windows leg. Rename them to near_m and far_m; the node names stay. Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 355ebbcf9..82755022c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -18059,14 +18059,14 @@ static int jgen_check(cbm_store_t *s, const char *project) { int64_t acc_y = jgen_node(s, project, "y", "Method", "Point.java", &gen_y); int64_t own_z = jgen_node(s, project, "z", "Method", "Point.java", &gen_z); int64_t norm = jgen_node(s, project, "norm", "Method", "Point.java", &gen_norm); - int64_t near = jgen_node(s, project, "near", "Method", "Caller.java", NULL); - int64_t far = jgen_node(s, project, "far", "Method", "Caller.java", NULL); + int64_t near_m = jgen_node(s, project, "near", "Method", "Caller.java", NULL); + int64_t far_m = jgen_node(s, project, "far", "Method", "Caller.java", NULL); int64_t data = jgen_node(s, project, "data", "Method", "Caller.java", NULL); int64_t make = jgen_node(s, project, "make", "Method", "Caller.java", NULL); int64_t stat = jgen_node(s, project, "stat", "Method", "Caller.java", NULL); int64_t refs = jgen_node(s, project, "refs", "Method", "Caller.java", NULL); JGEN_EXPECT(acc_x > 0 && acc_y > 0 && own_z > 0 && norm > 0 && acc_nn > 0); - JGEN_EXPECT(near > 0 && far > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); + JGEN_EXPECT(near_m > 0 && far_m > 0 && data > 0 && make > 0 && stat > 0 && refs > 0); /* The compiler's members are marked; the hand-written ones are not. */ JGEN_EXPECT(gen_x); JGEN_EXPECT(gen_y); @@ -18074,10 +18074,10 @@ static int jgen_check(cbm_store_t *s, const char *project) { JGEN_EXPECT(!gen_z); JGEN_EXPECT(!gen_norm); /* Bound by qualified name through the receiver's type ... */ - JGEN_EXPECT(jgen_calls(s, near, acc_y, NULL) == 1); + JGEN_EXPECT(jgen_calls(s, near_m, acc_y, NULL) == 1); /* ... and never by a short-name guess. */ - JGEN_EXPECT(jgen_calls(s, far, acc_y, NULL) == 0); - JGEN_EXPECT(jgen_calls(s, far, 0, "y") == 0); + JGEN_EXPECT(jgen_calls(s, far_m, acc_y, NULL) == 0); + JGEN_EXPECT(jgen_calls(s, far_m, 0, "y") == 0); /* Neither the accessor nor Spring's field `x`. */ JGEN_EXPECT(jgen_calls(s, data, 0, "x") == 0); /* No import names a generated member, so nothing follows one onto it. */