From 5fc107a0a61419faf0a2495ab923aba25a37ddb5 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Thu, 10 Sep 2026 20:02:50 +0000 Subject: [PATCH 01/16] fix(registry): recognize C# lower-case type keywords in receiver_chain_admits receiver_chain_admits() fast-admits any lower-case-rooted receiver chain as a value, not a type, which is true in Go/Python/Swift but not for C#'s built-in type aliases (int, string, bool, ...): those are lower-case KEYWORDS that name a type, so a static call like int.TryParse(...) skips the chain-consistency check and can fabricate a CALLS edge to any unrelated same-named method (#2121). Add a closed keyword set consulted only for a lower-case root, so every other language's lower-case value roots (vm.load, http.Get, os.path.join) are unaffected. Signed-off-by: Amir Fathi --- src/pipeline/registry.c | 28 ++++++++++++++++++++++++++-- tests/test_pipeline.c | 30 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index fa926d3c5..babd24cfa 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -925,6 +925,22 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal return match; } +/* C#'s built-in type aliases (int, string, bool, ...) are lower-case + * KEYWORDS naming a type, not an ordinary lower-case value root; closed list, + * no caller-language read, matching the guard below's own design. */ +static bool receiver_root_is_type_keyword(const char *root, size_t len) { + static const char *const keywords[] = { + "bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "long", + "object", "sbyte", "short", "string", "uint", "ulong", "ushort", "void", + }; + for (size_t i = 0; i < sizeof(keywords) / sizeof(keywords[0]); i++) { + if (strlen(keywords[i]) == len && strncmp(root, keywords[i], len) == 0) { + return true; + } + } + return false; +} + /* A dotted callee whose FIRST segment starts upper-case names a type — URLSession, * Calendar, JSONEncoder. That receiver chain is evidence the bare-name scorers * throw away, and throwing it away binds Foundation's URLSession.shared.data to @@ -933,7 +949,8 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal * appears somewhere in the chain. Calendar.utcGregorian.startOfDayUTC resolving * to AuthDTOs.Calendar.startOfDayUTC passes, because Calendar is in the chain. * - * Only an upper-case first segment is guarded. A lower-case root names a value + * Only an upper-case first segment is guarded, plus the closed set of + * lower-case type keywords above. Any other lower-case root names a value * (vm.load, http.Get, os.path.join) whose declared type the chain does not * show, so the chain proves nothing there and the call passes through * unchanged. A callee with no separator passes through as well. @@ -960,7 +977,14 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate return true; /* bare name — no receiver chain to judge */ } if (dotted[0] < 'A' || dotted[0] > 'Z') { - return true; /* lower-case root names a value, not a type */ + const char *first_dot = strchr(dotted, '.'); + size_t root_len = (size_t)((first_dot ? first_dot : last_dot) - dotted); + if (!receiver_root_is_type_keyword(dotted, root_len)) { + return true; /* lower-case root names a value, not a type */ + } + /* A reserved type keyword IS a type in receiver position + * (int.TryParse), so it falls through to the same chain-consistency + * check an upper-case root gets. */ } /* A name written in capitals with underscores is a constant holding a * value, not a type: ISO_4217_URL.lower is a string's own method. JSON and diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 738445fdf..7374c8992 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -9814,6 +9814,34 @@ TEST(registry_receiver_chain_ignores_lowercase_root_issue1893) { PASS(); } +/* Issue #2121: unlike vm.load's lower-case value root above, C#'s built-in + * type keywords (int, string, bool, ...) DO name a type and must get the + * same chain-consistency check an upper-case root gets. */ +TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "TryParse", "tmp.Decoys.TsidNode.TryParse", "Method"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0); + ASSERT_NULL(r.qualified_name); + + cbm_registry_free(reg); + PASS(); +} + +/* The true positive the new keyword check must not eat: the project really + * does define a type literally named "int" (or similar) and the chain shows + * it, so the match must still go through. */ +TEST(registry_receiver_chain_keeps_type_keyword_when_chain_matches_issue2121) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "TryParse", "tmp.int.TryParse", "Method"); + + cbm_resolution_t r = cbm_registry_resolve(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0); + ASSERT_STR_EQ(r.qualified_name, "tmp.int.TryParse"); + + cbm_registry_free(reg); + PASS(); +} + /* An unqualified callee has no chain at all and must pass through unchanged. */ TEST(registry_receiver_chain_ignores_bare_name_issue1893) { cbm_registry_t *reg = cbm_registry_new(); @@ -14155,6 +14183,8 @@ SUITE(pipeline) { RUN_TEST(registry_receiver_chain_refuses_library_suffix_match_issue1893); RUN_TEST(registry_receiver_chain_keeps_project_extension_issue1893); RUN_TEST(registry_receiver_chain_ignores_lowercase_root_issue1893); + RUN_TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121); + RUN_TEST(registry_receiver_chain_keeps_type_keyword_when_chain_matches_issue2121); RUN_TEST(registry_receiver_chain_ignores_bare_name_issue1893); RUN_TEST(registry_fuzzy_confidence_single); RUN_TEST(registry_fuzzy_confidence_distance); From 5a3e82520408db86161b8fff08175aa30a0adc6c Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 28 Sep 2026 08:45:37 +0000 Subject: [PATCH 02/16] fix(registry): gate C# type-keyword receiver check to CBM_LANG_CSHARP The #2121 fix treated C#'s reserved type keywords (int, string, object, ...) as value roots in receiver_chain_admits without checking the calling file's language. In Python, JS or Go those same spellings are ordinary identifiers, so a lower-case variable named object or string calling a method would fall through to the chain-consistency check and could drop a real CALLS edge outside C#. Add cbm_registry_resolve_lang(..., is_csharp) alongside the existing language-blind cbm_registry_resolve, and thread is_csharp from each file's own language at every call site that resolves a real receiver chain: pass_calls.c and pass_parallel.c (the sequential/parallel CALLS resolvers named in review), plus pass_semantic.c and pass_usages.c so INHERITS/DECORATES/THROWS/RW edges stay gated the same way rather than silently reverting to the pre-#2121 language-blind behavior. The two remaining language-blind callers (pass_calls.c's Python-only Depends scan, pass_lsp_cross.c's base-QN resolve which already excludes C#) are documented in place as never reaching a C# receiver chain. Adds a negative control test asserting a lower-case object/string receiver root still resolves as a value in a non-C# file, pinning the side of the gate a widened keyword list could quietly cost. Signed-off-by: Amir Fathi --- src/pipeline/pass_calls.c | 40 +++++++++++---- src/pipeline/pass_lsp_cross.c | 7 ++- src/pipeline/pass_parallel.c | 84 +++++++++++++++++++------------ src/pipeline/pass_semantic.c | 36 +++++++------ src/pipeline/pass_usages.c | 20 ++++---- src/pipeline/pipeline.h | 23 ++++++++- src/pipeline/registry.c | 95 +++++++++++++++++++++++++---------- tests/test_pipeline.c | 51 +++++++++++++++++-- 8 files changed, 257 insertions(+), 99 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954c..597cd0088 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -204,10 +204,14 @@ static void free_import_map(const char **keys, const char **vals, int count) { } } -/* Handle a route registration call: create Route node + HANDLES edge. */ +/* Handle a route registration call: create Route node + HANDLES edge. + * is_csharp is the caller's file-language gate for the #2121 C# receiver-chain + * check (threaded through to the handler-name resolve below); MUST match the + * pass_parallel.c twin (emit_route_registration). */ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const cbm_gbuf_node_t *source_node, const char *module_qn, - const char **imp_keys, const char **imp_vals, int imp_count) { + const char **imp_keys, const char **imp_vals, int imp_count, + bool is_csharp) { const char *method = cbm_service_pattern_route_method(call->callee_name); char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; @@ -227,8 +231,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca esc_fa); cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props); if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { - cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name, - module_qn, imp_keys, imp_vals, imp_count); + cbm_resolution_t hres = cbm_registry_resolve_lang(ctx->registry, call->second_arg_name, + module_qn, imp_keys, imp_vals, imp_count, + is_csharp); if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name); if (handler != NULL) { @@ -418,10 +423,11 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target, const cbm_resolution_t *res, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, - bool suppress_plain_calls) { + bool suppress_plain_calls, bool is_csharp) { cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name); if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') { - handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count); + handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count, + is_csharp); return; } if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { @@ -484,6 +490,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, return 0; } + /* #2121: C#'s lower-case type keywords (int, string, ...) are reserved + * words naming a type only in C#; every other language treats them as + * ordinary identifiers. Computed once here and threaded through every + * registry_resolve_lang / handle_route_registration call below; MUST + * match resolve_file_calls's computation in pass_parallel.c exactly. */ + bool is_csharp = lang == CBM_LANG_CSHARP; + /* LSP-resolved calls take precedence over registry-textual matching. * Unique-tail fallbacks are JVM-only (see cbm_pipeline_lsp_allow_tail_match). */ bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); @@ -506,7 +519,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, res.strategy = lsp->strategy; res.candidate_count = 1; emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, - imp_vals, imp_count, false); + imp_vals, imp_count, false, is_csharp); return SKIP_ONE; } } @@ -544,8 +557,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, } } - cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, - imp_keys, imp_vals, imp_count); + cbm_resolution_t res = cbm_registry_resolve_lang(ctx->registry, call->callee_name, module_qn, + imp_keys, imp_vals, imp_count, is_csharp); if (!res.qualified_name || res.qualified_name[0] == '\0') { /* Resolution is empty when the callee belongs to an EXTERNAL client * library whose source is not in the indexed tree (e.g. `requests.get`, @@ -569,7 +582,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg && call->first_string_arg[0] == '/') { handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals, - imp_count); + imp_count, is_csharp); return SKIP_ONE; } cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name); @@ -683,7 +696,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, return 0; } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, - imp_count, drop_plain_call); + imp_count, drop_plain_call, is_csharp); return SKIP_ONE; } @@ -920,6 +933,11 @@ static int scan_depends_in_sig(cbm_pipeline_ctx_t *ctx, const cbm_regex_t *re, c } memcpy(func_ref, scan + match[SKIP_ONE].rm_so, (size_t)ref_len); func_ref[ref_len] = '\0'; + /* Plain cbm_registry_resolve (is_csharp=false) is correct here, not a + * gap: cbm_pipeline_pass_fastapi_depends only calls this for files + * where files[i].language == CBM_LANG_PYTHON (see its own guard), so + * func_ref can never come from a C# file and the #2121 keyword gate + * never applies. */ cbm_resolution_t res = cbm_registry_resolve(ctx->registry, func_ref, module_qn, ik, iv, ic); if (res.qualified_name && res.qualified_name[0] != '\0') { const cbm_gbuf_node_t *sn = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name); diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 3b74cc1eb..522ea29c0 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -224,7 +224,12 @@ static bool pxc_base_strategy_is_weak(const char *strategy) { * pass_semantic.c::resolve_as_class — same registry, same type-like veto — * then additionally rejects weak short-name strategies (see above). * Returns NULL when the base is not a confidently-known project type; - * stdlib and third-party bases land here and keep their raw spelling. */ + * stdlib and third-party bases land here and keep their raw spelling. + * Plain cbm_registry_resolve (is_csharp=false) is correct here, not a gap: + * the only caller (pxc_join_base_qns, via pxc_build_lsp_def) is gated on + * pxc_lang_resolves_base_qns(lang), whose default case explicitly excludes + * C# ("Go / JVM / C# / C++ / Rust registrars qualify their own embedded + * types already"), so `raw` can never come from a C# file's base_classes. */ static const char *pxc_resolve_base_qn(const cbm_registry_t *reg, const char *raw, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f..d603703ac 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -567,11 +567,13 @@ static bool is_checked_exception(const char *name) { return true; } +/* is_csharp is the #2121 C# receiver-chain gate, threaded from the caller's + * file language; MUST match the pass_semantic.c twin of the same name. */ static const char *resolve_as_class(const cbm_registry_t *reg, const char *name, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count) { + const char **imp_vals, int imp_count, bool is_csharp) { cbm_resolution_t res = - cbm_registry_resolve(reg, name, module_qn, imp_keys, imp_vals, imp_count); + cbm_registry_resolve_lang(reg, name, module_qn, imp_keys, imp_vals, imp_count, is_csharp); if (!res.qualified_name || res.qualified_name[0] == '\0') { return NULL; } @@ -2127,7 +2129,7 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou const CBMCall *call, const char *route_path, const char *handler_ref, const char *module_qn, const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, - const char **ik, const char **iv, int ic) { + const char **ik, const char **iv, int ic, bool is_csharp) { const char *method = cbm_service_pattern_route_method(call->callee_name); char rqn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; @@ -2146,7 +2148,8 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou esc_rp); cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props); if (handler_ref && handler_ref[0] != '\0') { - cbm_resolution_t hres = cbm_registry_resolve(registry, handler_ref, module_qn, ik, iv, ic); + cbm_resolution_t hres = + cbm_registry_resolve_lang(registry, handler_ref, module_qn, ik, iv, ic, is_csharp); if (hres.qualified_name && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *h = cbm_gbuf_find_by_qn(main_gbuf, hres.qualified_name); if (h) { @@ -2439,7 +2442,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const cbm_resolution_t *res, const char *module_qn, const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf, const char **imp_keys, const char **imp_vals, int imp_count, - bool suppress_plain_calls) { + bool suppress_plain_calls, bool is_csharp) { cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name); const char *arg = call->first_string_arg; @@ -2466,7 +2469,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const char *route_path = find_route_path_in_args(call, &handler_ref); if (route_path) { emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, - registry, main_gbuf, imp_keys, imp_vals, imp_count); + registry, main_gbuf, imp_keys, imp_vals, imp_count, is_csharp); return; } /* No path found — fall through to normal CALLS edge */ @@ -2750,6 +2753,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool lsp_exact_idx_complete = true; bool lsp_legacy_idx_complete = true; bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); + /* #2121: MUST match resolve_single_call's computation in pass_calls.c + * exactly; see the note there. */ + bool is_csharp = lang == CBM_LANG_CSHARP; if (result->calls.count > 0 && result->resolved_calls.count > 0) { uint32_t capacity = (uint32_t)result->resolved_calls.count * 2u + 16u; lsp_exact_idx = cbm_ht_create(capacity); @@ -2896,8 +2902,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * exact LSP target and must fail closed rather than accepting a textual * registry match. */ if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution) { - res = cbm_registry_resolve(rc->registry, call->callee_name, module_qn, imp_keys, - imp_vals, imp_count); + res = cbm_registry_resolve_lang(rc->registry, call->callee_name, module_qn, imp_keys, + imp_vals, imp_count, is_csharp); } atomic_fetch_add_explicit(&rc->time_ns_rc_resolve, extract_now_ns() - _rc_t0, memory_order_relaxed); @@ -2979,7 +2985,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB .strategy = "service_pattern"}; emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, false); + imp_count, false, is_csharp); continue; } } @@ -2991,7 +2997,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB .strategy = "callee_suffix"}; emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, false); + imp_count, false, is_csharp); } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { /* Native `fetch()` (#856): only the global API once resolution * has failed to find a local/imported `fetch`. Call the low-level @@ -3043,7 +3049,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB if (url_or_topic) { emit_service_edge(ws->local_edge_buf, source_node, NULL, call, &res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, - false); + false, is_csharp); ws->calls_resolved++; } } @@ -3052,7 +3058,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB _rc_t0 = extract_now_ns(); emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, - drop_plain_call); + drop_plain_call, is_csharp); atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0, memory_order_relaxed); ws->calls_resolved++; @@ -3118,8 +3124,8 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, (lang == CBM_LANG_SQL) ? cbm_registry_resolve_lineage(rc->registry, usage->ref_name, module_qn, imp_keys, imp_vals, imp_count) - : cbm_registry_resolve(rc->registry, usage->ref_name, module_qn, imp_keys, - imp_vals, imp_count); + : cbm_registry_resolve_lang(rc->registry, usage->ref_name, module_qn, imp_keys, + imp_vals, imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } @@ -3168,7 +3174,8 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, /* Resolve throws/raises for one file. */ static void resolve_file_throws(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *rel, const char *module_qn, - const char **imp_keys, const char **imp_vals, int imp_count) { + const char **imp_keys, const char **imp_vals, int imp_count, + CBMLanguage lang) { for (int t = 0; t < result->throws.count; t++) { CBMThrow *thr = &result->throws.items[t]; if (!thr->exception_name || !thr->enclosing_func_qn) { @@ -3183,8 +3190,9 @@ static void resolve_file_throws(resolve_ctx_t *rc, resolve_worker_state_t *ws, continue; } const char *edge_type = is_checked_exception(thr->exception_name) ? "THROWS" : "RAISES"; - cbm_resolution_t res = cbm_registry_resolve(rc->registry, thr->exception_name, module_qn, - imp_keys, imp_vals, imp_count); + cbm_resolution_t res = + cbm_registry_resolve_lang(rc->registry, thr->exception_name, module_qn, imp_keys, + imp_vals, imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } @@ -3210,8 +3218,9 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (!src) { continue; } - cbm_resolution_t res = cbm_registry_resolve(rc->registry, rw->var_name, module_qn, imp_keys, - imp_vals, imp_count); + cbm_resolution_t res = + cbm_registry_resolve_lang(rc->registry, rw->var_name, module_qn, imp_keys, imp_vals, + imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } @@ -3238,12 +3247,14 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi /* Resolve base_classes → INHERITS edges for one definition. */ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, const CBMDefinition *def, const cbm_gbuf_node_t *node, - const char *mq, const char **ik, const char **iv, int ic) { + const char *mq, const char **ik, const char **iv, int ic, + bool is_csharp) { if (!def->base_classes) { return; } for (int b = 0; def->base_classes[b]; b++) { - const char *bqn = resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic); + const char *bqn = + resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, is_csharp); if (!bqn) { continue; } @@ -3262,7 +3273,8 @@ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, /* Resolve decorators → DECORATES edges for one definition. */ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws, const CBMDefinition *def, const cbm_gbuf_node_t *node, - const char *mq, const char **ik, const char **iv, int ic) { + const char *mq, const char **ik, const char **iv, int ic, + bool is_csharp) { if (!def->decorators) { return; } @@ -3272,14 +3284,14 @@ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws if (fn[0] == '\0') { continue; } - cbm_resolution_t res = cbm_registry_resolve(rc->registry, fn, mq, ik, iv, ic); + cbm_resolution_t res = cbm_registry_resolve_lang(rc->registry, fn, mq, ik, iv, ic, is_csharp); if ((!res.qualified_name || res.qualified_name[0] == '\0') && !strchr(fn, '.')) { /* C# attributes are referenced by their short name (`[Log]`) but * declared with an `Attribute` suffix (`class LogAttribute`). */ char with_suffix[CBM_SZ_256]; int wn = snprintf(with_suffix, sizeof(with_suffix), "%sAttribute", fn); if (wn > 0 && (size_t)wn < sizeof(with_suffix)) { - res = cbm_registry_resolve(rc->registry, with_suffix, mq, ik, iv, ic); + res = cbm_registry_resolve_lang(rc->registry, with_suffix, mq, ik, iv, ic, is_csharp); } } const cbm_gbuf_node_t *dn = NULL; @@ -3329,10 +3341,15 @@ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws } } -/* Resolve INHERITS + DECORATES + IMPLEMENTS for one file. */ +/* Resolve INHERITS + DECORATES + IMPLEMENTS for one file. is_csharp is the + * #2121 gate for the definition-edge resolves; the impl_traits loop below + * passes false explicitly since impl_traits is populated only by the Rust + * extractor and can never come from a C# file (mirrors pass_semantic.c's + * resolve_impl_traits). */ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *module_qn, - const char **imp_keys, const char **imp_vals, int imp_count) { + const char **imp_keys, const char **imp_vals, int imp_count, + bool is_csharp) { for (int d = 0; d < result->defs.count; d++) { CBMDefinition *def = &result->defs.items[d]; if (!def->qualified_name) { @@ -3342,8 +3359,10 @@ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (!node) { continue; } - resolve_def_inherits(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); - resolve_def_decorators(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count); + resolve_def_inherits(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count, + is_csharp); + resolve_def_decorators(rc, ws, def, node, module_qn, imp_keys, imp_vals, imp_count, + is_csharp); } for (int t = 0; t < result->impl_traits.count; t++) { CBMImplTrait *it = &result->impl_traits.items[t]; @@ -3351,9 +3370,9 @@ static void resolve_file_semantic(resolve_ctx_t *rc, resolve_worker_state_t *ws, continue; } const char *tqn = resolve_as_class(rc->registry, it->trait_name, module_qn, imp_keys, - imp_vals, imp_count); + imp_vals, imp_count, false); const char *sqn = resolve_as_class(rc->registry, it->struct_name, module_qn, imp_keys, - imp_vals, imp_count); + imp_vals, imp_count, false); if (!tqn || !sqn) { continue; } @@ -3680,7 +3699,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { /* ── THROWS / RAISES ───────────────────────────────────── */ _ph_t0 = extract_now_ns(); - resolve_file_throws(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count); + resolve_file_throws(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang); atomic_fetch_add_explicit(&rc->time_ns_throws, extract_now_ns() - _ph_t0, memory_order_relaxed); @@ -3691,7 +3710,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { /* ── INHERITS + DECORATES + IMPLEMENTS ──────────────────── */ _ph_t0 = extract_now_ns(); - resolve_file_semantic(rc, ws, result, module_qn, imp_keys, imp_vals, imp_count); + resolve_file_semantic(rc, ws, result, module_qn, imp_keys, imp_vals, imp_count, + lang == CBM_LANG_CSHARP); atomic_fetch_add_explicit(&rc->time_ns_semantic, extract_now_ns() - _ph_t0, memory_order_relaxed); diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 48dab0083..e7eb8ecd6 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -174,12 +174,14 @@ static void free_import_map(const char **keys, const char **vals, int count) { } } -/* Resolve a class/type name through the registry. Returns borrowed QN or NULL. */ +/* Resolve a class/type name through the registry. Returns borrowed QN or NULL. + * is_csharp is the #2121 C# receiver-chain gate, threaded from the caller's + * file language; MUST match the pass_parallel.c twin of the same name. */ static const char *resolve_as_class(const cbm_registry_t *reg, const char *name, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count) { + const char **imp_vals, int imp_count, bool is_csharp) { cbm_resolution_t res = - cbm_registry_resolve(reg, name, module_qn, imp_keys, imp_vals, imp_count); + cbm_registry_resolve_lang(reg, name, module_qn, imp_keys, imp_vals, imp_count, is_csharp); if (!res.qualified_name || res.qualified_name[0] == '\0') { return NULL; } @@ -546,14 +548,14 @@ static void synth_decorator_qn(const char *func_name, char *out, size_t outsz) { /* Resolve one decorator and create DECORATES edge. */ static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *node, const char *decorator, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count, int *count) { + const char **imp_vals, int imp_count, int *count, bool is_csharp) { char func_name[CBM_SZ_256]; extract_decorator_func(decorator, func_name, sizeof(func_name)); if (func_name[0] == '\0') { return; } - cbm_resolution_t res = - cbm_registry_resolve(ctx->registry, func_name, module_qn, imp_keys, imp_vals, imp_count); + cbm_resolution_t res = cbm_registry_resolve_lang(ctx->registry, func_name, module_qn, imp_keys, + imp_vals, imp_count, is_csharp); if ((!res.qualified_name || res.qualified_name[0] == '\0') && !strchr(func_name, '.')) { /* C# attributes are referenced by their short name (`[Log]`) but declared * with the conventional `Attribute` suffix (`class LogAttribute`). Retry @@ -561,8 +563,8 @@ static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *no char with_suffix[CBM_SZ_256]; int wn = snprintf(with_suffix, sizeof(with_suffix), "%sAttribute", func_name); if (wn > 0 && (size_t)wn < sizeof(with_suffix)) { - res = cbm_registry_resolve(ctx->registry, with_suffix, module_qn, imp_keys, imp_vals, - imp_count); + res = cbm_registry_resolve_lang(ctx->registry, with_suffix, module_qn, imp_keys, + imp_vals, imp_count, is_csharp); } } const cbm_gbuf_node_t *dec = NULL; @@ -613,7 +615,7 @@ static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *no static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, int *inherits_count, - int *decorates_count) { + int *decorates_count, bool is_csharp) { if (!def->qualified_name) { return; } @@ -624,7 +626,7 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * if (def->base_classes) { for (int b = 0; def->base_classes[b]; b++) { const char *base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, - imp_keys, imp_vals, imp_count); + imp_keys, imp_vals, imp_count, is_csharp); if (!base_qn) { continue; } @@ -646,7 +648,7 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * if (def->decorators) { for (int dc = 0; def->decorators[dc]; dc++) { resolve_decorator(ctx, node, def->decorators[dc], module_qn, imp_keys, imp_vals, - imp_count, decorates_count); + imp_count, decorates_count, is_csharp); } } } @@ -672,7 +674,10 @@ static CBMFileResult *sem_get_or_extract(cbm_pipeline_ctx_t *ctx, int file_idx, return r; } -/* Resolve Rust impl traits for one file's extraction results. */ +/* Resolve Rust impl traits for one file's extraction results. impl_traits is + * populated only by the Rust extractor, so trait_name/struct_name can never + * come from a C# file: is_csharp=false is correct by construction, not a + * guess, and matches this same call passing false in pass_parallel.c. */ static int resolve_impl_traits(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count) { @@ -683,12 +688,12 @@ static int resolve_impl_traits(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res continue; } const char *trait_qn = resolve_as_class(ctx->registry, it->trait_name, module_qn, imp_keys, - imp_vals, imp_count); + imp_vals, imp_count, false); if (!trait_qn) { continue; } const char *struct_qn = resolve_as_class(ctx->registry, it->struct_name, module_qn, - imp_keys, imp_vals, imp_count); + imp_keys, imp_vals, imp_count, false); if (!struct_qn) { continue; } @@ -735,9 +740,10 @@ int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *f ps_module_is_dir(files[i].language)); /* ── INHERITS + DECORATES from definitions ──────────────── */ + bool is_csharp = files[i].language == CBM_LANG_CSHARP; for (int d = 0; d < result->defs.count; d++) { sem_process_def_edges(ctx, &result->defs.items[d], module_qn, imp_keys, imp_vals, - imp_count, &inherits_count, &decorates_count); + imp_count, &inherits_count, &decorates_count, is_csharp); } /* ── IMPLEMENTS from impl_traits (Rust) ─────────────────── */ diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 9738f7198..af99038a1 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -200,8 +200,8 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res (lang == CBM_LANG_SQL) ? cbm_registry_resolve_lineage(ctx->registry, usage->ref_name, module_qn, imp_keys, imp_vals, imp_count) - : cbm_registry_resolve(ctx->registry, usage->ref_name, module_qn, imp_keys, - imp_vals, imp_count); + : cbm_registry_resolve_lang(ctx->registry, usage->ref_name, module_qn, imp_keys, + imp_vals, imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } @@ -248,7 +248,7 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res /* Resolve THROWS/RAISES edges for one file's extracted throws. */ static int resolve_throw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *rel, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count) { + const char **imp_vals, int imp_count, CBMLanguage lang) { int resolved = 0; for (int t = 0; t < result->throws.count; t++) { CBMThrow *thr = &result->throws.items[t]; @@ -262,8 +262,9 @@ static int resolve_throw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res } const char *edge_type = is_checked_exception(thr->exception_name) ? "THROWS" : "RAISES"; - cbm_resolution_t res = cbm_registry_resolve(ctx->registry, thr->exception_name, module_qn, - imp_keys, imp_vals, imp_count); + cbm_resolution_t res = + cbm_registry_resolve_lang(ctx->registry, thr->exception_name, module_qn, imp_keys, + imp_vals, imp_count, lang == CBM_LANG_CSHARP); const cbm_gbuf_node_t *tgt = NULL; if (res.qualified_name && res.qualified_name[0]) { @@ -295,8 +296,9 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result continue; } - cbm_resolution_t res = cbm_registry_resolve(ctx->registry, rw->var_name, module_qn, - imp_keys, imp_vals, imp_count); + cbm_resolution_t res = + cbm_registry_resolve_lang(ctx->registry, rw->var_name, module_qn, imp_keys, imp_vals, + imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } @@ -379,8 +381,8 @@ int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fil usage_resolved += resolve_usage_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count, files[i].language); - throw_resolved += - resolve_throw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count); + throw_resolved += resolve_throw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, + imp_count, files[i].language); rw_resolved += resolve_rw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count, files[i].language); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0..edaafd887 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -231,11 +231,32 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * Never returns a data relation (Table/View): relations are lineage-only * registry members and common table names (users, orders, config) collide with * code identifiers in every language, so the default resolve vetoes them - * centrally instead of relying on per-consumer label checks. */ + * centrally instead of relying on per-consumer label checks. + * Language-blind: equivalent to cbm_registry_resolve_lang(..., false). Use + * this only where the caller cannot fabricate a C# receiver chain (tests, + * Rust-only impl_traits, Python-only Depends scanning, base-class resolvers + * pxc_lang_resolves_base_qns already excludes C# from); every resolver that + * walks a real file's receiver chains should call cbm_registry_resolve_lang + * with that file's own language instead. */ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count); +/* Same as cbm_registry_resolve, plus the #2121 C# receiver-chain gate: + * `int.TryParse`-style lower-case type-keyword roots are only treated as a + * type when is_csharp is true (see receiver_chain_admits in registry.c). + * `is_csharp` MUST be computed from the FILE being resolved (its own + * `lang == CBM_LANG_CSHARP`), never guessed or hardcoded true: the sequential + * resolvers (pass_calls.c, pass_usages.c, pass_semantic.c) and the parallel + * resolver (pass_parallel.c) MUST compute it identically at their mirrored + * call sites or CALLS/INHERITS/DECORATES/USAGE/etc. edges diverge between the + * two build modes, exactly like the existing cbm_suppress_cross_language_suffix_match + * / cbm_weak_member_unique_name_exempt lockstep pairs. */ +cbm_resolution_t cbm_registry_resolve_lang(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, const char **import_map_keys, + const char **import_map_vals, int import_map_count, + bool is_csharp); + /* Relation-permitting resolve for SQL FROM/JOIN lineage usages ONLY — the one * consumer allowed to bind Table/View targets. Uncached (the per-file resolve * cache stores the default variant's relation-vetoed answers). */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 6762b3d5e..9820f04de 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1162,8 +1162,9 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal } /* C#'s built-in type aliases (int, string, bool, ...) are lower-case - * KEYWORDS naming a type, not an ordinary lower-case value root; closed list, - * no caller-language read, matching the guard below's own design. */ + * KEYWORDS naming a type, not an ordinary lower-case value root. Closed list; + * the caller (receiver_chain_admits) gates this on the file's language, since + * the same spellings are ordinary identifiers in Python/JS/Go/etc. */ static bool receiver_root_is_type_keyword(const char *root, size_t len) { static const char *const keywords[] = { "bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "long", @@ -1185,15 +1186,27 @@ static bool receiver_root_is_type_keyword(const char *root, size_t len) { * appears somewhere in the chain. Calendar.utcGregorian.startOfDayUTC resolving * to AuthDTOs.Calendar.startOfDayUTC passes, because Calendar is in the chain. * - * Only an upper-case first segment is guarded, plus the closed set of - * lower-case type keywords above. Any other lower-case root names a value - * (vm.load, http.Get, os.path.join) whose declared type the chain does not - * show, so the chain proves nothing there and the call passes through - * unchanged. A callee with no separator passes through as well. + * Only an upper-case first segment is guarded, plus, for C# only, the closed + * set of lower-case type keywords above (#2121: `int.TryParse`, `string.Join`). + * Any other lower-case root names a value (vm.load, http.Get, os.path.join) + * whose declared type the chain does not show, so the chain proves nothing + * there and the call passes through unchanged. A callee with no separator + * passes through as well. * - * Language agnostic by design: the registry holds no language, and every - * language that writes receiver chains gains the same protection. */ -static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn) { + * `is_csharp` is the caller's per-language gate, kept out of + * receiver_root_is_type_keyword for the same reason as cbm_weak_member_unique_name_exempt + * and friends above: the words are reserved type keywords ONLY in C#. In + * Python/JS/Go, `object`, `string`, `char` etc. are ordinary identifiers, so a + * lower-case variable named `object` calling a method must keep resolving as + * a value root there. Every call site threading this MUST use the file's own + * language (`lang == CBM_LANG_CSHARP`); the sequential (pass_calls.c, + * pass_usages.c, pass_semantic.c) and parallel (pass_parallel.c) resolvers + * MUST agree on the same computation or CALLS/INHERITS/etc. edges diverge + * between the two build modes. Everything else in this function (the + * upper-case-root ancestry walk, the foreign-root refusal) stays + * language-agnostic by design, exactly as before #2121. */ +static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn, + bool is_csharp) { /* Normalize "::" -> "." so the chain composes with dotted candidate QNs, * the same way qualified_suffix_match does. */ char dotted[CBM_SZ_512]; @@ -1215,12 +1228,13 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate if (dotted[0] < 'A' || dotted[0] > 'Z') { const char *first_dot = strchr(dotted, '.'); size_t root_len = (size_t)((first_dot ? first_dot : last_dot) - dotted); - if (!receiver_root_is_type_keyword(dotted, root_len)) { - return true; /* lower-case root names a value, not a type */ + if (!is_csharp || !receiver_root_is_type_keyword(dotted, root_len)) { + return true; /* lower-case root names a value, not a type (or the + * keyword list simply does not apply outside C#) */ } /* A reserved type keyword IS a type in receiver position * (int.TryParse), so it falls through to the same chain-consistency - * check an upper-case root gets. */ + * check an upper-case root gets. C# only, per the gate above. */ } /* A name written in capitals with underscores is a constant holding a * value, not a type: ISO_4217_URL.lower is a string's own method. JSON and @@ -1279,7 +1293,7 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate /* Strategy 3+4: Name lookup + suffix match */ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_vals, - int import_count) { + int import_count, bool is_csharp) { const char *lookup = simple_name(callee_name); qn_array_t *arr = cbm_ht_get(r->by_name, lookup); if (!arr || arr->count == 0) { @@ -1301,7 +1315,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char /* Strategy 3: unique name */ if (arr->count == SKIP_ONE) { - if (!receiver_chain_admits(callee_name, arr->items[0])) { + if (!receiver_chain_admits(callee_name, arr->items[0], is_csharp)) { return empty_result(); } double conf = CONF_UNIQUE_NAME; @@ -1319,7 +1333,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char const char *best = best_by_import_distance((const char **)arr->items, qn_test_flags(arr), arr->count, module_qn); if (best) { - if (!receiver_chain_admits(callee_name, best)) { + if (!receiver_chain_admits(callee_name, best, is_csharp)) { return empty_result(); } double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count); @@ -1329,10 +1343,15 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char } /* The strategy chain shared by both public resolve variants (no caching here — - * cbm_registry_resolve owns the per-file cache). */ + * cbm_registry_resolve owns the per-file cache). + * + * `is_csharp` reaches only receiver_chain_admits via resolve_name_lookup; + * see the comment there for the lockstep requirement across pass_calls.c / + * pass_usages.c / pass_semantic.c / pass_parallel.c. */ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, - const char **import_map_vals, int import_map_count) { + const char **import_map_vals, int import_map_count, + bool is_csharp) { /* Split callee at the first path separator: "pkg.Func" → prefix="pkg", * suffix="Func". Rust/C++ use "::" rather than ".", so honor whichever * separator appears first ("lib::square" → prefix="lib", suffix="square"). @@ -1369,21 +1388,27 @@ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const ch } if (!(res.qualified_name && res.qualified_name[0])) { /* Strategy 3+4: name lookup */ - res = resolve_name_lookup(r, callee_name, module_qn, import_map_vals, import_map_count); + res = resolve_name_lookup(r, callee_name, module_qn, import_map_vals, import_map_count, + is_csharp); } return res; } -cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name, - const char *module_qn, const char **import_map_keys, - const char **import_map_vals, int import_map_count) { +cbm_resolution_t cbm_registry_resolve_lang(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, const char **import_map_keys, + const char **import_map_vals, int import_map_count, + bool is_csharp) { if (!r || !callee_name) { return empty_result(); } /* Per-file cache: same callee_name in N call sites → 1 chain walk * + N-1 O(1) hash hits. module_qn is constant per file so the - * cache key only needs callee_name. */ + * cache key only needs callee_name, and so is is_csharp: every + * caller within one file's cbm_registry_resolve_cache_begin/_end + * bracket derives it from that same file's language, so a cache hit + * can never answer a callee_name resolved under a different + * is_csharp than the one asking (#2121 follow-up). */ if (_resolve_cache) { resolve_cache_entry_t *cached = (resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, callee_name); @@ -1393,7 +1418,7 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle } cbm_resolution_t res = registry_resolve_chain(r, callee_name, module_qn, import_map_keys, - import_map_vals, import_map_count); + import_map_vals, import_map_count, is_csharp); /* Data relations (Table/View) are lineage-only registry members: common * table names (users, orders, config) collide with code identifiers across @@ -1424,6 +1449,19 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle return res; } +/* Language-blind entry point, kept for the many callers (tests included) that + * never touch a receiver chain a C# keyword could apply to. Always resolves + * as if the caller were not C#: is_csharp=false leaves this variant + * byte-identical to cbm_registry_resolve before #2121's C# gate existed. + * Every production call site that resolves a real C# file's receiver chains + * calls cbm_registry_resolve_lang directly with its own file's language. */ +cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *callee_name, + const char *module_qn, const char **import_map_keys, + const char **import_map_vals, int import_map_count) { + return cbm_registry_resolve_lang(r, callee_name, module_qn, import_map_keys, import_map_vals, + import_map_count, false); +} + cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count) { @@ -1434,9 +1472,14 @@ cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const cha * Deliberately uncached: the per-file cache is keyed by bare callee_name * and stores the relation-vetoed answer of the default variant — sharing * it would poison one variant with the other's semantics. SQL files hold - * few distinct relation refs, so the chain walk stays cheap. */ + * few distinct relation refs, so the chain walk stays cheap. + * + * is_csharp is hardcoded false: the only two callers (pass_usages.c, + * pass_parallel.c) route here exclusively for CBM_LANG_SQL files, which + * can never be CBM_LANG_CSHARP, so the C# receiver-chain gate never + * applies to this path. */ return registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals, - import_map_count); + import_map_count, false); } /* ── Fuzzy Resolve ──────────────────────────────────────────────── */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 031ed9ea8..2d47ee2d4 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10759,12 +10759,16 @@ TEST(registry_receiver_chain_ignores_lowercase_root_issue1893) { /* Issue #2121: unlike vm.load's lower-case value root above, C#'s built-in * type keywords (int, string, bool, ...) DO name a type and must get the - * same chain-consistency check an upper-case root gets. */ + * same chain-consistency check an upper-case root gets. is_csharp=true here + * because this is exactly the C# file the maintainer's review (PR #2170) + * asked the gate to apply to: every production caller reaches this branch + * only when the file being resolved is CBM_LANG_CSHARP. */ TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "TryParse", "tmp.Decoys.TsidNode.TryParse", "Method"); - cbm_resolution_t r = cbm_registry_resolve(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0); + cbm_resolution_t r = + cbm_registry_resolve_lang(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0, true); ASSERT_NULL(r.qualified_name); cbm_registry_free(reg); @@ -10773,18 +10777,56 @@ TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121) { /* The true positive the new keyword check must not eat: the project really * does define a type literally named "int" (or similar) and the chain shows - * it, so the match must still go through. */ + * it, so the match must still go through. is_csharp=true so this actually + * exercises the keyword branch's ancestry walk, not the is_csharp=false + * early return (which would pass through for the wrong reason). */ TEST(registry_receiver_chain_keeps_type_keyword_when_chain_matches_issue2121) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "TryParse", "tmp.int.TryParse", "Method"); - cbm_resolution_t r = cbm_registry_resolve(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0); + cbm_resolution_t r = + cbm_registry_resolve_lang(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0, true); ASSERT_STR_EQ(r.qualified_name, "tmp.int.TryParse"); cbm_registry_free(reg); PASS(); } +/* Review ask #1/#2 (PR #2170, DeusData): the type-keyword branch above is a + * C#-only rule (closed list, gated on is_csharp) and must not trade away + * recall in a language where the same spellings are ordinary identifiers. + * `object` and `string` are ordinary lower-case variable names in Python/Go; + * a variable named `object` calling a method (`object.TryParse` shaped + * exactly like the refusal test above) must still resolve as a value root + * when is_csharp is false: the negative control pinning the other side of + * the gate. */ +TEST(registry_receiver_chain_keyword_list_gated_to_csharp_issue2121) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "TryParse", "tmp.Decoys.TsidNode.TryParse", "Method"); + + /* Same callee/candidate shape as + * registry_receiver_chain_refuses_lowercase_type_keyword_issue2121 above, + * but resolved as a Python/Go file (is_csharp=false): "object" must be + * treated as an ordinary value root, not a reserved type keyword. */ + cbm_resolution_t r = + cbm_registry_resolve_lang(reg, "object.TryParse", "tmp.Demo", NULL, NULL, 0, false); + ASSERT_STR_EQ(r.qualified_name, "tmp.Decoys.TsidNode.TryParse"); + ASSERT_STR_EQ(r.strategy, "unique_name"); + + /* The language-blind cbm_registry_resolve wrapper IS cbm_registry_resolve_lang + * with is_csharp=false, not a separate code path: confirm the every-day + * entry point behaves identically for a second keyword spelling. */ + cbm_registry_t *reg2 = cbm_registry_new(); + cbm_registry_add(reg2, "Join", "tmp.Decoys.Joiner.Join", "Method"); + cbm_resolution_t r2 = cbm_registry_resolve(reg2, "string.Join", "tmp.Demo", NULL, NULL, 0); + ASSERT_STR_EQ(r2.qualified_name, "tmp.Decoys.Joiner.Join"); + ASSERT_STR_EQ(r2.strategy, "unique_name"); + + cbm_registry_free(reg); + cbm_registry_free(reg2); + PASS(); +} + /* An unqualified callee has no chain at all and must pass through unchanged. */ TEST(registry_receiver_chain_ignores_bare_name_issue1893) { cbm_registry_t *reg = cbm_registry_new(); @@ -15281,6 +15323,7 @@ SUITE(pipeline) { RUN_TEST(registry_receiver_chain_ignores_lowercase_root_issue1893); RUN_TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121); RUN_TEST(registry_receiver_chain_keeps_type_keyword_when_chain_matches_issue2121); + RUN_TEST(registry_receiver_chain_keyword_list_gated_to_csharp_issue2121); RUN_TEST(registry_receiver_chain_ignores_bare_name_issue1893); RUN_TEST(registry_fuzzy_confidence_single); RUN_TEST(registry_fuzzy_confidence_distance); From f4efd0b86844bf2ac965ba9ec30c8805cc774e40 Mon Sep 17 00:00:00 2001 From: BobbieBarker <4307099+BobbieBarker@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:50:28 -0700 Subject: [PATCH 03/16] fix(registry): score a candidate on the extractor's test verdict, not its name cbm_registry_add discarded the caller's knowledge of whether a symbol is test code and recovered it from the qualified name: is_test_qn scanned the QN for "test", "Test", "mock", "stub", "spec", "fake" or "Fixture" anywhere in the string. REG_TEST_PENALTY then keeps non-test candidates ahead of test ones, so that scan decides which definition an unresolved call resolves to. An unanchored substring match over a path is wrong in both directions, and the false positives are not exotic. On one 970-file Elixir tree the scan calls 794 of 20,882 Function and Method nodes test code. 516 of those match on "spec", and 268 match ONLY because "inspect" contains it: forge_symphony.pipeline.impl .inspect_optional, .pr_tracker.continuation_intent.inspect_record and every other symbol under an operator_inspect module is ordinary production code that was being ranked below real test code. "latest" and "contest" match "test" the same way. In the other direction a test helper under a directory that spells none of those words is scored as production. The extractor already knows. CBMDefinition.is_test is set from the file's test-ness (cbm_is_test_file) OR the definition's own attributes, and it is the value the store filters on. cbm_registry_add now takes it and stores it beside the QN, and is_test_qn is deleted. Three things follow from the verdict no longer being recomputable: candidate_score loses its tri-state `int is_test` and its `-1: ask` fallback, because there is nothing left to ask. qn_test_flags stops returning NULL. Both filtered resolve paths have to carry the verdicts with the candidates. resolve_multi_with_imports already copied a parallel array but guarded it on `flags ?`; filter_import_reachable carried none at all, so the fuzzy path passed NULL whenever it had filtered and silently fell back to the QN scan. index_under_name drops an entry whose verdict it cannot store, rather than leaving the flag array short. A missing verdict scores as non-test, which would let an unranked candidate outrank real code; one missing candidate is the smaller error. pipeline_incremental.c seeds the registry from persisted nodes and has no CBMDefinition, so it reads the same flag out of properties_json through cbm_node_is_test, exported from pass_tests.c. Its comment requires the incremental registry to mirror the full-index one exactly, and both now answer from the same value. Measured by indexing four corpora with a binary built from this commit's parent and with this one, and diffing the `edges` rows keyed on (source QN, target QN, type): corpus moved retargeted lost (resolve) lost (similarity) Elixir 970-file 920 919 1 0 ripgrep 14.1.1 353 193 30 130 tokio 1.40.0 1,548 1,426 86 36 rust-analyzer 5,160 4,817 219 124 Almost every moved edge is the same call site landing on a different target. 336 resolution edges are lost across the four, where the new winner is refused by a check the old one passed. The similarity column is SEMANTICALLY_RELATED, which the post-passes rebuild wholesale over a drifting corpus and which moves on its own. This is a behaviour change across every indexed project, not a signature change, and the gating suite does not see it: 8,236 tests pass with the scan and without it. REG_TEST_PENALTY had no direct coverage, because none of the 95 cbm_registry_add call sites in the suite registered a symbol the scan would flag. tests/test_registry.c: three tests. registry_prefers_non_test_over_test_ candidate pins the penalty itself. registry_test_verdict_comes_from_the_caller _not_the_qn pins where the verdict comes from, using proj.latest.parse (production code the scan reads as a test) against proj.harness.parse (a test the scan reads as production); it fails with the scan restored and the signature kept. registry_sole_test_candidate_still_resolves pins that the penalty orders candidates without vetoing them. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> --- src/pipeline/pass_definitions.c | 2 +- src/pipeline/pass_parallel.c | 2 +- src/pipeline/pass_tests.c | 6 +- src/pipeline/pipeline.h | 5 +- src/pipeline/pipeline_incremental.c | 2 +- src/pipeline/pipeline_internal.h | 6 ++ src/pipeline/registry.c | 134 ++++++++---------------- tests/test_pipeline.c | 78 +++++++------- tests/test_registry.c | 157 ++++++++++++++++++---------- 9 files changed, 199 insertions(+), 193 deletions(-) diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7c7ed6884..c6bb872c4 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -335,7 +335,7 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed * through the same predicate, so the three registries cannot diverge. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label, def->is_test); } 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 e85ad015f..7a760fdcb 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1588,7 +1588,7 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d /* Registry membership is defined ONCE by cbm_label_is_registry_symbol * (helpers.c) — see pass_definitions.c for the per-label rationale. */ if (cbm_label_is_registry_symbol(def->label)) { - cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label); + cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label, def->is_test); (*reg_entries)++; } const cbm_gbuf_node_t *def_node = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name); diff --git a/src/pipeline/pass_tests.c b/src/pipeline/pass_tests.c index 9cade02a0..65c099bf0 100644 --- a/src/pipeline/pass_tests.c +++ b/src/pipeline/pass_tests.c @@ -44,7 +44,7 @@ static const char *itoa_log(int val) { } /* Check if a node has is_test:true in its properties JSON. */ -static bool node_is_test(const cbm_gbuf_node_t *n) { +bool cbm_node_is_test(const cbm_gbuf_node_t *n) { if (!n || !n->properties_json) { return false; } @@ -227,13 +227,13 @@ static int create_tests_edges(cbm_pipeline_ctx_t *ctx) { } bool src_is_test = - node_is_test(src) || (src->file_path && cbm_is_test_path(src->file_path)); + cbm_node_is_test(src) || (src->file_path && cbm_is_test_path(src->file_path)); if (!src_is_test) { continue; } bool tgt_is_test = - node_is_test(tgt) || (tgt->file_path && cbm_is_test_path(tgt->file_path)); + cbm_node_is_test(tgt) || (tgt->file_path && cbm_is_test_path(tgt->file_path)); if (tgt_is_test) { continue; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index c0fddc7e7..9d47e826f 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -229,8 +229,11 @@ cbm_registry_t *cbm_registry_new(void); void cbm_registry_free(cbm_registry_t *r); /* Register a function/method/class. All strings are copied. */ +/* `is_test` is the extractor's verdict for this symbol (CBMDefinition.is_test: + * the file's test-ness OR the definition's own test attributes). The registry + * stores it and scores with it; it does not derive test-ness from the QN. */ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, - const char *label); + const char *label, bool is_test); /* Resolve a callee name using prioritized strategies. * import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL. diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 154518658..cc90c3194 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1041,7 +1041,7 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { if (!incr_label_is_registry_symbol(node->label)) { return; } - cbm_registry_add(r, node->name, node->qualified_name, node->label); + cbm_registry_add(r, node->name, node->qualified_name, node->label, cbm_node_is_test(node)); } static void free_incremental_result_cache(CBMFileResult **cache, int count) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index e7782adef..6c7ac96e9 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -166,6 +166,12 @@ typedef struct { typedef bool (*cbm_result_want_fn)(const CBMFileResult *header); CBMFileResult *cbm_pipeline_result_acquire(const cbm_pipeline_ctx_t *ctx, CBMFileResult **cache, int i, cbm_result_want_fn want, bool *loaded); +/* True when a graph-buffer node carries is_test:true in its properties JSON. + * The incremental registry seed has only the persisted node, not the + * CBMDefinition the definition passes hand to cbm_registry_add, and both must + * give the registry the same verdict for the same symbol. */ +bool cbm_node_is_test(const cbm_gbuf_node_t *n); + void cbm_pipeline_result_release(CBMFileResult *r, bool loaded); /* Log the store counters, close and delete the store, drop the latch. */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index bdf6f5944..c0db4d71f 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -28,6 +28,7 @@ enum { REG_MAX_CANDIDATES = 256 }; #include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */ #include "foundation/compat.h" /* CBM_TLS */ #include "foundation/hash_table.h" +#include "foundation/log.h" #include "foundation/dyn_array.h" #include "foundation/platform.h" @@ -72,16 +73,16 @@ const char *cbm_confidence_band(double score) { /* ── Internal types ──────────────────────────────────────────────── */ -/* Array of QN strings for byName index, with the test/mock verdict of each - * name cached beside it: the scorer asked is_test_qn for every candidate of - * every unresolved call, and the answer depends only on the name, which never - * changes once registered (waste sanitizer scaling lane, 2026-09-17). Field - * order and names match CBM_DYN_ARRAY(char *) so cbm_da_push works on it. */ +/* Array of QN strings for byName index, with each entry's test verdict beside + * it. The verdict is the extractor's, handed to cbm_registry_add by the caller + * that already computed it from the file path and the definition's attributes; + * it is stored rather than derived, so every entry always has one. Field order + * and names match CBM_DYN_ARRAY(char *) so cbm_da_push works on it. */ typedef struct { char **items; int count; int cap; - uint8_t *is_test; /* is_test_qn(items[i]), one byte per entry */ + uint8_t *is_test; /* the caller's verdict for items[i], one byte per entry */ int is_test_cap; } qn_array_t; @@ -151,78 +152,19 @@ static int common_prefix_len(const char *a, const char *b) { enum { REG_TEST_PENALTY = 1000 }; -/* Check if a qualified name looks like a test/mock path: does it contain any of - * Test, test, Mock, mock, Stub, stub, Fake, fake, Fixture, spec? One pass that - * dispatches on the first character instead of ten strstr scans of the whole - * name (this ran for every candidate of every unresolved call: the scaling - * lane's x4.0, waste sanitizer 2026-09-17). */ -static bool is_test_qn(const char *qn) { - if (!qn) { - return false; - } - for (const char *p = qn; *p; p++) { - switch (*p) { - case 'T': - if (strncmp(p, "Test", 4) == 0) { - return true; - } - break; - case 't': - if (strncmp(p, "test", 4) == 0) { - return true; - } - break; - case 'M': - if (strncmp(p, "Mock", 4) == 0) { - return true; - } - break; - case 'm': - if (strncmp(p, "mock", 4) == 0) { - return true; - } - break; - case 'S': - if (strncmp(p, "Stub", 4) == 0) { - return true; - } - break; - case 's': - if (strncmp(p, "stub", 4) == 0 || strncmp(p, "spec", 4) == 0) { - return true; - } - break; - case 'F': - if (strncmp(p, "Fake", 4) == 0 || strncmp(p, "Fixture", 7) == 0) { - return true; - } - break; - case 'f': - if (strncmp(p, "fake", 4) == 0) { - return true; - } - break; - default: - break; - } - } - return false; -} - -/* The cached test verdicts, only when they cover every entry: a failed growth - * leaves the cache short, and then the scorer asks per candidate as before. */ +/* The stored test verdicts. index_under_name drops an entry it cannot record a + * verdict for, so this covers every entry of a non-empty bucket. */ static const uint8_t *qn_test_flags(const qn_array_t *arr) { - return arr->is_test && arr->is_test_cap >= arr->count ? arr->is_test : NULL; + return arr->is_test; } /* Score a candidate for tiebreaking. Higher = better. * Layer 1: Non-test code preferred over test code (+1000) * Layer 2: Namespace proximity via common prefix length (+plen) - * `is_test` is the cached verdict when the caller has one (-1: ask). */ -static int candidate_score(const char *candidate_qn, const char *module_qn, int is_test) { + * `is_test` is the verdict stored for this candidate at registration. */ +static int candidate_score(const char *candidate_qn, const char *module_qn, bool is_test) { int score = 0; - bool test = is_test >= 0 ? is_test != 0 : is_test_qn(candidate_qn); - if (!test) { + if (!is_test) { score += REG_TEST_PENALTY; } score += common_prefix_len(candidate_qn, module_qn); @@ -268,8 +210,7 @@ static const char *best_by_import_distance(const char **candidates, const uint8_ const char *best = NULL; int best_score = CBM_NOT_FOUND; for (int i = 0; i < count; i++) { - int score = - candidate_score(candidates[i], module_qn, is_test_flags ? (int)is_test_flags[i] : -1); + int score = candidate_score(candidates[i], module_qn, is_test_flags[i] != 0); if (score > best_score || (score == best_score && best && candidate_outranks_on_tie(candidates[i], best))) { best_score = score; @@ -863,7 +804,8 @@ void cbm_registry_free(cbm_registry_t *r) { * * No array dedup needed: cbm_registry_add's exact-map check guarantees the QN * is new, and it calls this at most once per distinct key. */ -static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn) { +static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn, + bool is_test) { qn_array_t *arr = cbm_ht_get(r->by_name, key); if (!arr) { arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); @@ -872,7 +814,7 @@ static void index_under_name(cbm_registry_t *r, const char *key, const char *own int before = arr->count; cbm_da_push(arr, (char *)owned_qn); if (arr->count == before) { - return; /* the name could not be recorded: no verdict to cache */ + return; /* the name could not be recorded: no verdict to store */ } if (arr->count > arr->is_test_cap) { int want = arr->cap > 0 ? arr->cap : arr->count; @@ -883,13 +825,19 @@ static void index_under_name(cbm_registry_t *r, const char *key, const char *own arr->is_test_cap = want; } } - if (arr->count <= arr->is_test_cap) { - arr->is_test[arr->count - SKIP_ONE] = is_test_qn(owned_qn) ? 1 : 0; + if (arr->count > arr->is_test_cap) { + /* The verdict can no longer be recomputed from the QN, so an entry + * without one would be scored as non-test and outrank real code. Drop + * it instead: one missing candidate beats a wrong winner. */ + arr->count = before; + cbm_log_warn("registry.verdict_alloc_failed", "key", key); + return; } + arr->is_test[arr->count - SKIP_ONE] = is_test ? 1 : 0; } void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, - const char *label) { + const char *label, bool is_test) { if (!r || !qualified_name || !label) { return; } @@ -945,14 +893,14 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified * `name` is NULL or empty only for callers that have no symbol name to * give; those have the derived key and nothing else. */ const char *derived = simple_name(qualified_name); - index_under_name(r, derived, owned_qn); + index_under_name(r, derived, owned_qn, is_test); /* '#' is a QN fence, and extract_defs.c's rust_cfg_qualified_name is the * only thing in the tree that mints one today. A grammar that starts * minting a '#' opts into this second key by doing so, whatever it means by * the fence: its symbols become reachable under the passed name as well, * and they share that name's bucket with everything else filed under it. */ if (name && name[0] && strchr(derived, '#') && strcmp(name, derived) != 0) { - index_under_name(r, name, owned_qn); + index_under_name(r, name, owned_qn, is_test); } } @@ -1111,9 +1059,7 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const int fcount = 0; for (int i = 0; i < arr->count && fcount < CBM_SZ_256; i++) { if (is_import_reachable(arr->items[i], import_vals, import_count)) { - if (flags) { - filtered_test[fcount] = flags[i]; - } + filtered_test[fcount] = flags[i]; filtered[fcount] = arr->items[i]; fcount++; } @@ -1123,8 +1069,7 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const return (cbm_resolution_t){filtered[0], "suffix_match", conf, arr->count}; } if (fcount > SKIP_ONE) { - const char *best = - best_by_import_distance(filtered, flags ? filtered_test : NULL, fcount, module_qn); + const char *best = best_by_import_distance(filtered, filtered_test, fcount, module_qn); if (best) { double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, fcount); return (cbm_resolution_t){best, "suffix_match", conf, fcount}; @@ -1454,11 +1399,15 @@ cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const cha /* ── Fuzzy Resolve ──────────────────────────────────────────────── */ /* Filter candidates by import reachability. Returns count of reachable. */ -static int filter_import_reachable(const char **candidates, int count, const char **import_vals, - int import_count, const char **out, int max_out) { +/* Keeps `out_flags` in step with `out`: the verdicts are stored per entry and + * cannot be recomputed from the QN, so a filtered view has to carry its own. */ +static int filter_import_reachable(const char **candidates, const uint8_t *flags, int count, + const char **import_vals, int import_count, const char **out, + uint8_t *out_flags, int max_out) { int n = 0; for (int i = 0; i < count && n < max_out; i++) { if (is_import_reachable(candidates[i], import_vals, import_count)) { + out_flags[n] = flags[i]; out[n++] = candidates[i]; } } @@ -1498,13 +1447,17 @@ cbm_fuzzy_result_t cbm_registry_fuzzy_resolve(const cbm_registry_t *r, const cha /* Multiple candidates: filter by import reachability */ const char *filtered[CBM_SZ_256]; + uint8_t filtered_flags[CBM_SZ_256]; int fcount = arr->count; const char **fptr = (const char **)arr->items; + const uint8_t *fflags = qn_test_flags(arr); if (have_imports) { - fcount = filter_import_reachable((const char **)arr->items, arr->count, import_map_vals, - import_map_count, filtered, CBM_SZ_256); + fcount = filter_import_reachable((const char **)arr->items, qn_test_flags(arr), arr->count, + import_map_vals, import_map_count, filtered, + filtered_flags, CBM_SZ_256); fptr = filtered; + fflags = filtered_flags; } if (fcount == 0) { @@ -1524,8 +1477,7 @@ cbm_fuzzy_result_t cbm_registry_fuzzy_resolve(const cbm_registry_t *r, const cha {fptr[0], "fuzzy", candidate_count_penalty(CONF_FUZZY_SINGLE, arr->count), arr->count}, true}; } - const char *best = best_by_import_distance( - fptr, fptr == (const char **)arr->items ? qn_test_flags(arr) : NULL, fcount, module_qn); + const char *best = best_by_import_distance(fptr, fflags, fcount, module_qn); if (!best) { return no_match; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 568740f17..f5a1ddd4e 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10930,8 +10930,8 @@ TEST(helm_parse_chart_no_deps_issue338) { TEST(registry_resolve_single_candidate) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); - cbm_registry_add(reg, "ValidateOrder", "svcB.validators.ValidateOrder", "Function"); + cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function", false); + cbm_registry_add(reg, "ValidateOrder", "svcB.validators.ValidateOrder", "Function", false); /* Normal resolve unique name */ cbm_resolution_t r = cbm_registry_resolve(reg, "CreateOrder", "svcC.caller", NULL, NULL, 0); @@ -10949,7 +10949,7 @@ TEST(registry_resolve_single_candidate) { TEST(registry_fuzzy_nonexistent) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); + cbm_registry_add(reg, "CreateOrder", "svcA.handlers.CreateOrder", "Function", false); cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "NonExistent", "svcC.caller", NULL, NULL, 0); @@ -10961,8 +10961,8 @@ TEST(registry_fuzzy_nonexistent) { TEST(registry_fuzzy_multiple_best_by_distance) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "svcA.handlers.Process", "Function"); - cbm_registry_add(reg, "Process", "svcB.handlers.Process", "Function"); + cbm_registry_add(reg, "Process", "svcA.handlers.Process", "Function", false); + cbm_registry_add(reg, "Process", "svcB.handlers.Process", "Function", false); /* Caller in svcA → prefer svcA */ cbm_fuzzy_result_t fr = @@ -10981,7 +10981,7 @@ TEST(registry_fuzzy_multiple_best_by_distance) { TEST(registry_fuzzy_simple_name_extraction) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "DoWork", "myproject.utils.DoWork", "Function"); + cbm_registry_add(reg, "DoWork", "myproject.utils.DoWork", "Function", false); /* Deeply qualified name → extract "DoWork" */ cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "some.deep.module.DoWork", @@ -11006,8 +11006,8 @@ TEST(registry_fuzzy_empty) { TEST(registry_exists) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "pkg.module.Foo", "Function"); - cbm_registry_add(reg, "Bar", "pkg.module.Bar", "Method"); + cbm_registry_add(reg, "Foo", "pkg.module.Foo", "Function", false); + cbm_registry_add(reg, "Bar", "pkg.module.Bar", "Method", false); ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Foo")); ASSERT_TRUE(cbm_registry_exists(reg, "pkg.module.Bar")); @@ -11020,7 +11020,7 @@ TEST(registry_exists) { TEST(registry_confidence_import_map) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); + cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function", false); const char *keys[] = {"other"}; const char *vals[] = {"proj.other"}; @@ -11035,7 +11035,7 @@ TEST(registry_confidence_import_map) { TEST(registry_confidence_import_map_suffix) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.other.sub.Foo", "Function"); + cbm_registry_add(reg, "Foo", "proj.other.sub.Foo", "Function", false); const char *keys[] = {"other"}; const char *vals[] = {"proj.other"}; @@ -11050,7 +11050,7 @@ TEST(registry_confidence_import_map_suffix) { TEST(registry_confidence_same_module) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); + cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function", false); cbm_resolution_t r = cbm_registry_resolve(reg, "Foo", "proj.pkg", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Foo"); @@ -11063,7 +11063,7 @@ TEST(registry_confidence_same_module) { TEST(registry_confidence_unique_name) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); + cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function", false); cbm_resolution_t r = cbm_registry_resolve(reg, "Bar", "proj.unrelated", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, "proj.pkg.Bar"); @@ -11076,8 +11076,8 @@ TEST(registry_confidence_unique_name) { TEST(registry_confidence_suffix_match) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function"); - cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function"); + cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function", false); + cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function", false); cbm_resolution_t r = cbm_registry_resolve(reg, "Process", "proj.svcA.caller", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, "proj.svcA.Process"); @@ -11093,7 +11093,7 @@ TEST(registry_confidence_suffix_match) { * wrong target — and with one candidate it won the top name-only confidence. */ TEST(registry_receiver_chain_refuses_library_unique_name_issue1893) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "data", "HomeboxUI.PickedFile.data", "Variable"); + cbm_registry_add(reg, "data", "HomeboxUI.PickedFile.data", "Variable", false); cbm_resolution_t r = cbm_registry_resolve(reg, "URLSession.shared.data", "HomeboxUI.Net", NULL, NULL, 0); @@ -11107,8 +11107,8 @@ TEST(registry_receiver_chain_refuses_library_unique_name_issue1893) { * the final name and import distance picks the winner. */ TEST(registry_receiver_chain_refuses_library_suffix_match_issue1893) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "data", "HomeboxUI.PickedFile.data", "Variable"); - cbm_registry_add(reg, "data", "HomeboxUI.Payload.data", "Variable"); + cbm_registry_add(reg, "data", "HomeboxUI.PickedFile.data", "Variable", false); + cbm_registry_add(reg, "data", "HomeboxUI.Payload.data", "Variable", false); cbm_resolution_t r = cbm_registry_resolve(reg, "URLSession.shared.data", "HomeboxUI.Net", NULL, NULL, 0); @@ -11122,7 +11122,7 @@ TEST(registry_receiver_chain_refuses_library_suffix_match_issue1893) { * so Calendar really is in the receiver chain. */ TEST(registry_receiver_chain_keeps_project_extension_issue1893) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "startOfDayUTC", "AuthDTOs.Calendar.startOfDayUTC", "Method"); + cbm_registry_add(reg, "startOfDayUTC", "AuthDTOs.Calendar.startOfDayUTC", "Method", false); cbm_resolution_t r = cbm_registry_resolve(reg, "Calendar.utcGregorian.startOfDayUTC", "HomeboxUI.Stats", NULL, NULL, 0); @@ -11142,7 +11142,7 @@ TEST(registry_receiver_chain_keeps_project_extension_issue1893) { TEST(registry_receiver_chain_admits_factory_chain_into_nested_type) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "putList", "org.elasticsearch.common.settings.Settings.Builder.putList", - "Method"); + "Method", false); cbm_resolution_t r = cbm_registry_resolve( reg, "Settings.builder().putList", "org.elasticsearch.index.IndexSettings", NULL, NULL, 0); @@ -11153,7 +11153,7 @@ TEST(registry_receiver_chain_admits_factory_chain_into_nested_type) { * .toBuilder().setWarningsHandler -> RequestOptions.Builder.setWarningsHandler. */ cbm_registry_add(reg, "setWarningsHandler", "org.elasticsearch.client.RequestOptions.Builder.setWarningsHandler", - "Method"); + "Method", false); r = cbm_registry_resolve(reg, "RequestOptions.DEFAULT.toBuilder().setWarningsHandler", "org.elasticsearch.client.Rest", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, @@ -11169,7 +11169,7 @@ TEST(registry_receiver_chain_admits_factory_chain_into_nested_type) { TEST(registry_receiver_chain_still_refuses_foreign_root_with_ancestry_rule) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "getEncoder", "org.elasticsearch.index.codec.DocOffsetsCodec.getEncoder", - "Method"); + "Method", false); cbm_resolution_t r = cbm_registry_resolve( reg, "Base64.getEncoder", "org.elasticsearch.index.IndexSettings", NULL, NULL, 0); @@ -11179,7 +11179,7 @@ TEST(registry_receiver_chain_still_refuses_foreign_root_with_ancestry_rule) { * org.elasticsearch.common.Math.toIntExact through "Math" appearing as an * ancestry segment ONLY IF that segment really is the type — here it is, and * that is the correct outcome (the project's own Math.toIntExact). */ - cbm_registry_add(reg, "toIntExact", "org.elasticsearch.common.Math.toIntExact", "Method"); + cbm_registry_add(reg, "toIntExact", "org.elasticsearch.common.Math.toIntExact", "Method", false); r = cbm_registry_resolve(reg, "Math.toIntExact", "org.elasticsearch.index.IndexSettings", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, "org.elasticsearch.common.Math.toIntExact"); @@ -11192,7 +11192,7 @@ TEST(registry_receiver_chain_still_refuses_foreign_root_with_ancestry_rule) { * must not look at it, or every ordinary vm.load style call would be refused. */ TEST(registry_receiver_chain_ignores_lowercase_root_issue1893) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "load", "HomeboxUI.EntityListViewModel.load", "Method"); + cbm_registry_add(reg, "load", "HomeboxUI.EntityListViewModel.load", "Method", false); cbm_resolution_t r = cbm_registry_resolve(reg, "vm.load", "HomeboxUI.Views", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, "HomeboxUI.EntityListViewModel.load"); @@ -11205,7 +11205,7 @@ TEST(registry_receiver_chain_ignores_lowercase_root_issue1893) { /* An unqualified callee has no chain at all and must pass through unchanged. */ TEST(registry_receiver_chain_ignores_bare_name_issue1893) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "helper", "proj.pkg.helper", "Function"); + cbm_registry_add(reg, "helper", "proj.pkg.helper", "Function", false); cbm_resolution_t r = cbm_registry_resolve(reg, "helper", "proj.other", NULL, NULL, 0); ASSERT_STR_EQ(r.qualified_name, "proj.pkg.helper"); @@ -11217,7 +11217,7 @@ TEST(registry_receiver_chain_ignores_bare_name_issue1893) { TEST(registry_fuzzy_confidence_single) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function"); + cbm_registry_add(reg, "Handler", "proj.svc.Handler", "Function", false); cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "unknownPkg.Handler", "proj.caller", NULL, NULL, 0); @@ -11231,8 +11231,8 @@ TEST(registry_fuzzy_confidence_single) { TEST(registry_fuzzy_confidence_distance) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function"); - cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function"); + cbm_registry_add(reg, "Process", "proj.svcA.Process", "Function", false); + cbm_registry_add(reg, "Process", "proj.svcB.Process", "Function", false); cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(reg, "unknownPkg.Process", "proj.svcA.other", NULL, NULL, 0); @@ -11246,8 +11246,8 @@ TEST(registry_fuzzy_confidence_distance) { TEST(registry_negative_import_rejects) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Process", "proj.billing.Process", "Function"); - cbm_registry_add(reg, "Process", "proj.handler.Process", "Function"); + cbm_registry_add(reg, "Process", "proj.billing.Process", "Function", false); + cbm_registry_add(reg, "Process", "proj.handler.Process", "Function", false); /* Import only handler's module → should prefer handler */ const char *keys[] = {"handler"}; @@ -11261,7 +11261,7 @@ TEST(registry_negative_import_rejects) { TEST(registry_fuzzy_import_penalty) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function"); + cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function", false); /* Has imports but billing not imported → confidence halved */ const char *keys[] = {"other"}; @@ -11278,7 +11278,7 @@ TEST(registry_fuzzy_import_penalty) { TEST(registry_fuzzy_no_import_map_passthrough) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function"); + cbm_registry_add(reg, "Handler", "proj.billing.Handler", "Function", false); /* NULL import map → no penalty, full fuzzy confidence */ cbm_fuzzy_result_t fr = @@ -11292,10 +11292,10 @@ TEST(registry_fuzzy_no_import_map_passthrough) { TEST(registry_find_by_name) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); - cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); - cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); - cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method"); + cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function", false); + cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function", false); + cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function", false); + cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method", false); /* FindByName returns all entries for "Foo" */ const char **foos = NULL; @@ -12628,10 +12628,10 @@ TEST(registry_is_import_reachable) { /* Port of FindEndingWith portion from Go TestFunctionRegistry in pipeline_test.go */ TEST(registry_find_ending_with) { cbm_registry_t *reg = cbm_registry_new(); - cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function"); - cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function"); - cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function"); - cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method"); + cbm_registry_add(reg, "Foo", "proj.pkg.Foo", "Function", false); + cbm_registry_add(reg, "Bar", "proj.pkg.Bar", "Function", false); + cbm_registry_add(reg, "Foo", "proj.other.Foo", "Function", false); + cbm_registry_add(reg, "transform", "proj.utils.DataProcessor.transform", "Method", false); /* FindEndingWith "DataProcessor.transform" → 1 match */ const char **matches = NULL; diff --git a/tests/test_registry.c b/tests/test_registry.c index f77b1460b..beb125024 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -191,7 +191,7 @@ TEST(registry_free_null) { TEST(registry_add_and_exists) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "main", "proj.cmd.main", "Function"); + cbm_registry_add(r, "main", "proj.cmd.main", "Function", false); ASSERT_EQ(cbm_registry_size(r), 1); ASSERT_TRUE(cbm_registry_exists(r, "proj.cmd.main")); ASSERT_FALSE(cbm_registry_exists(r, "proj.cmd.other")); @@ -199,10 +199,52 @@ TEST(registry_add_and_exists) { PASS(); } +/* REG_TEST_PENALTY had no direct coverage. Of the registry call sites in this + * suite, none registered a symbol the scorer would treat as a test, so the + * layer that keeps production code ahead of test code was never exercised. + * These three pin it, and pin where the verdict comes from. */ +TEST(registry_prefers_non_test_over_test_candidate) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "helper", "proj.app.helper", "Function", false); + cbm_registry_add(r, "helper", "proj.harness.helper", "Function", true); + + cbm_resolution_t res = cbm_registry_resolve(r, "helper", "proj.other", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.app.helper"); + cbm_registry_free(r); + PASS(); +} + +/* The verdict is the caller's, not the QN's spelling. "latest" contains "test", + * so the scan this replaces read ordinary production code as a test and pushed + * it below a real one; "harness" spells nothing, so the same scan read a test + * as production. Both verdicts here are the extractor's, and both are right. */ +TEST(registry_test_verdict_comes_from_the_caller_not_the_qn) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "parse", "proj.latest.parse", "Function", false); + cbm_registry_add(r, "parse", "proj.harness.parse", "Function", true); + + cbm_resolution_t res = cbm_registry_resolve(r, "parse", "proj.other", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.latest.parse"); + cbm_registry_free(r); + PASS(); +} + +/* A bucket of one is returned whatever its verdict: the penalty orders + * candidates, it does not veto them. */ +TEST(registry_sole_test_candidate_still_resolves) { + cbm_registry_t *r = cbm_registry_new(); + cbm_registry_add(r, "only", "proj.harness.only", "Function", true); + + cbm_resolution_t res = cbm_registry_resolve(r, "only", "proj.other", NULL, NULL, 0); + ASSERT_STR_EQ(res.qualified_name, "proj.harness.only"); + cbm_registry_free(r); + PASS(); +} + TEST(registry_label_of) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Foo", "proj.pkg.Foo", "Class"); - cbm_registry_add(r, "bar", "proj.pkg.bar", "Function"); + cbm_registry_add(r, "Foo", "proj.pkg.Foo", "Class", false); + cbm_registry_add(r, "bar", "proj.pkg.bar", "Function", false); ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.pkg.Foo"), "Class"); ASSERT_STR_EQ(cbm_registry_label_of(r, "proj.pkg.bar"), "Function"); @@ -214,9 +256,9 @@ TEST(registry_label_of) { TEST(registry_find_by_name) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "main", "proj.cmd.main", "Function"); - cbm_registry_add(r, "main", "proj.srv.main", "Function"); - cbm_registry_add(r, "helper", "proj.util.helper", "Function"); + cbm_registry_add(r, "main", "proj.cmd.main", "Function", false); + cbm_registry_add(r, "main", "proj.srv.main", "Function", false); + cbm_registry_add(r, "helper", "proj.util.helper", "Function", false); const char **out = NULL; int count = 0; @@ -235,8 +277,8 @@ TEST(registry_find_by_name) { TEST(registry_no_duplicates) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "main", "proj.cmd.main", "Function"); - cbm_registry_add(r, "main", "proj.cmd.main", "Function"); /* duplicate */ + cbm_registry_add(r, "main", "proj.cmd.main", "Function", false); + cbm_registry_add(r, "main", "proj.cmd.main", "Function", false); /* duplicate */ ASSERT_EQ(cbm_registry_size(r), 1); const char **out = NULL; @@ -252,7 +294,7 @@ TEST(registry_no_duplicates) { TEST(resolve_same_module) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "helper", "proj.pkg.service.helper", "Function"); + cbm_registry_add(r, "helper", "proj.pkg.service.helper", "Function", false); /* Call "helper" from the same module → should resolve */ cbm_resolution_t res = cbm_registry_resolve(r, "helper", "proj.pkg.service", NULL, NULL, 0); @@ -270,9 +312,9 @@ TEST(resolve_same_module) { * Foo::Bar::sub()) where the same sub name exists in multiple packages. */ TEST(resolve_qualified_disambiguates_same_name) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "save", "proj.lib.App.Alpha.save", "Function"); - cbm_registry_add(r, "save", "proj.lib.App.Beta.save", "Function"); - cbm_registry_add(r, "save", "proj.lib.App.Gamma.save", "Function"); + cbm_registry_add(r, "save", "proj.lib.App.Alpha.save", "Function", false); + cbm_registry_add(r, "save", "proj.lib.App.Beta.save", "Function", false); + cbm_registry_add(r, "save", "proj.lib.App.Gamma.save", "Function", false); /* Each fully-qualified call routes to its own package. */ cbm_resolution_t a = @@ -313,8 +355,8 @@ TEST(resolve_qualified_disambiguates_same_name) { * pick arbitrarily under the high-confidence qualified_suffix strategy. */ TEST(resolve_qualified_ambiguous_tail_falls_through) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "run", "proj.svcA.Foo.Bar.run", "Function"); - cbm_registry_add(r, "run", "proj.svcB.Foo.Bar.run", "Function"); + cbm_registry_add(r, "run", "proj.svcA.Foo.Bar.run", "Function", false); + cbm_registry_add(r, "run", "proj.svcB.Foo.Bar.run", "Function", false); /* "Foo::Bar::run" tail matches BOTH candidates → not unique → fall through. */ cbm_resolution_t res = @@ -332,7 +374,7 @@ TEST(resolve_qualified_ambiguous_tail_falls_through) { * `add` callee could ever reach it. */ TEST(registry_indexes_by_passed_name_not_qn_tail) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "add", "proj.lib.add#cfg(test)", "Function"); + cbm_registry_add(r, "add", "proj.lib.add#cfg(test)", "Function", false); cbm_resolution_t res = cbm_registry_resolve(r, "add", "proj.lib.caller", NULL, NULL, 0); ASSERT_STR_EQ(res.qualified_name, "proj.lib.add#cfg(test)"); @@ -357,9 +399,9 @@ TEST(registry_indexes_by_passed_name_not_qn_tail) { * lookup a bare module reference needs. */ TEST(registry_indexes_a_dotted_name_under_its_tail_too) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "resource.aws_instance.web", "proj.main.resource.aws_instance.web", - "Class"); - cbm_registry_add(r, "helper.py", "proj.pkg.helper", "Module"); + cbm_registry_add(r, "resource.aws_instance.web", "proj.main.resource.aws_instance.web", "Class", + false); + cbm_registry_add(r, "helper.py", "proj.pkg.helper", "Module", false); cbm_resolution_t tail = cbm_registry_resolve(r, "web", "proj.main", NULL, NULL, 0); ASSERT_STR_EQ(tail.qualified_name, "proj.main.resource.aws_instance.web"); @@ -377,7 +419,7 @@ TEST(registry_indexes_a_dotted_name_under_its_tail_too) { TEST(resolve_import_map) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function"); + cbm_registry_add(r, "Process", "proj.pkg.worker.Process", "Function", false); /* Import map: "worker" → "proj.pkg.worker" */ const char *keys[] = {"worker"}; @@ -400,9 +442,9 @@ TEST(resolve_import_map) { * file. Regression for the @/lib/auth-style import case. */ TEST(resolve_import_map_bare_function) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "requireAdmin", "proj.lib.authorization.requireAdmin", "Function"); + cbm_registry_add(r, "requireAdmin", "proj.lib.authorization.requireAdmin", "Function", false); /* Same name in another module — without the fix this is what gets picked. */ - cbm_registry_add(r, "requireAdmin", "proj.lib.users.requireAdmin", "Function"); + cbm_registry_add(r, "requireAdmin", "proj.lib.users.requireAdmin", "Function", false); const char *keys[] = {"requireAdmin"}; const char *vals[] = {"proj.lib.authorization"}; @@ -423,7 +465,7 @@ TEST(resolve_import_map_bare_function) { * Regression for #875. */ TEST(resolve_import_map_bare_alias) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "scan_bash", "proj.security_scan.scan_bash", "Function"); + cbm_registry_add(r, "scan_bash", "proj.security_scan.scan_bash", "Function", false); /* Import map: alias "_scan_bash" → FULL SYMBOL QN (not the module). */ const char *keys[] = {"_scan_bash"}; const char *vals[] = {"proj.security_scan.scan_bash"}; @@ -440,10 +482,10 @@ TEST(resolve_import_map_bare_alias) { * already on main via #875/#979; this locks the Yui G1 shape. */ TEST(resolve_import_map_aliased_from_import) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "execute", "proj.services.satori_bridge.gate.execute", "Function"); + cbm_registry_add(r, "execute", "proj.services.satori_bridge.gate.execute", "Function", false); /* Alias ghost must not win if somehow registered. */ cbm_registry_add(r, "bridge_execute", "proj.services.satori_bridge.gate.bridge_execute", - "Function"); + "Function", false); const char *keys[] = {"bridge_execute"}; const char *vals[] = {"proj.services.satori_bridge.gate.execute"}; @@ -459,7 +501,7 @@ TEST(resolve_import_map_aliased_from_import) { TEST(resolve_unique_name) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "UniqueFunc", "proj.deep.path.UniqueFunc", "Function"); + cbm_registry_add(r, "UniqueFunc", "proj.deep.path.UniqueFunc", "Function", false); /* Call "UniqueFunc" — only one candidate project-wide */ cbm_resolution_t res = @@ -473,7 +515,7 @@ TEST(resolve_unique_name) { TEST(resolve_unresolved) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "foo", "proj.pkg.foo", "Function"); + cbm_registry_add(r, "foo", "proj.pkg.foo", "Function", false); /* Call "nonexistent" — not in registry */ cbm_resolution_t res = cbm_registry_resolve(r, "nonexistent", "proj.other", NULL, NULL, 0); @@ -490,7 +532,7 @@ TEST(resolve_many_nodes) { char name[32], qn[64]; snprintf(name, sizeof(name), "func_%d", i); snprintf(qn, sizeof(qn), "proj.pkg.func_%d", i); - cbm_registry_add(r, name, qn, "Function"); + cbm_registry_add(r, name, qn, "Function", false); } ASSERT_EQ(cbm_registry_size(r), 500); @@ -528,8 +570,8 @@ TEST(confidence_band_speculative) { TEST(resolve_suffix_match) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Process", "proj.svcA.Process", "Function"); - cbm_registry_add(r, "Process", "proj.svcB.Process", "Function"); + cbm_registry_add(r, "Process", "proj.svcA.Process", "Function", false); + cbm_registry_add(r, "Process", "proj.svcB.Process", "Function", false); /* Caller in svcA — should prefer svcA via import distance */ cbm_resolution_t res = cbm_registry_resolve(r, "Process", "proj.svcA.caller", NULL, NULL, 0); @@ -552,7 +594,7 @@ TEST(resolve_caps_unresolvably_ambiguous_names) { for (int i = 0; i < 300; i++) { char qn[64]; snprintf(qn, sizeof(qn), "proj.mod%d.flags", i); - cbm_registry_add(r, "flags", qn, "Variable"); + cbm_registry_add(r, "flags", qn, "Variable", false); } cbm_resolution_t res = cbm_registry_resolve(r, "flags", "proj.other.caller", NULL, NULL, 0); ASSERT_TRUE(res.qualified_name == NULL || res.qualified_name[0] == '\0'); @@ -570,7 +612,7 @@ TEST(resolve_caps_unresolvably_ambiguous_names) { TEST(resolve_import_map_suffix) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Foo", "proj.other.sub.Foo", "Function"); + cbm_registry_add(r, "Foo", "proj.other.sub.Foo", "Function", false); const char *keys[] = {"other"}; const char *vals[] = {"proj.other"}; @@ -592,7 +634,7 @@ TEST(resolve_is_import_reachable) { /* Test import reachability through unique_name confidence penalty. * is_import_reachable is static, so we test it indirectly. */ cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Helper", "proj.shared.utils.Helper", "Function"); + cbm_registry_add(r, "Helper", "proj.shared.utils.Helper", "Function", false); /* With import covering the module → full confidence */ const char *keys1[] = {"utils"}; @@ -615,7 +657,7 @@ TEST(resolve_is_import_reachable) { TEST(resolve_import_reachable_prefix) { /* "proj.handler.sub.Process" should be reachable via import "proj.handler" */ cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Process", "proj.handler.sub.Process", "Function"); + cbm_registry_add(r, "Process", "proj.handler.sub.Process", "Function", false); const char *keys[] = {"handler"}; const char *vals[] = {"proj.handler"}; @@ -669,8 +711,8 @@ TEST(reach_cache_memo_matches_uncached_across_files) { TEST(negative_import_rejects_unimported) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Process", "proj.billing.Process", "Function"); - cbm_registry_add(r, "Process", "proj.handler.Process", "Function"); + cbm_registry_add(r, "Process", "proj.billing.Process", "Function", false); + cbm_registry_add(r, "Process", "proj.handler.Process", "Function", false); /* Import only handler's module — suffix_match should prefer handler */ const char *keys[] = {"handler"}; @@ -686,8 +728,8 @@ TEST(negative_import_rejects_unimported) { TEST(fuzzy_resolve_single_candidate) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); - cbm_registry_add(r, "ValidateOrder", "svcB.validators.ValidateOrder", "Function"); + cbm_registry_add(r, "CreateOrder", "svcA.handlers.CreateOrder", "Function", false); + cbm_registry_add(r, "ValidateOrder", "svcB.validators.ValidateOrder", "Function", false); /* FuzzyResolve should find by simple name even with unknown prefix */ cbm_fuzzy_result_t fr = @@ -701,7 +743,7 @@ TEST(fuzzy_resolve_single_candidate) { TEST(fuzzy_resolve_nonexistent) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "CreateOrder", "svcA.handlers.CreateOrder", "Function"); + cbm_registry_add(r, "CreateOrder", "svcA.handlers.CreateOrder", "Function", false); cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(r, "NonExistent", "svcC.caller", NULL, NULL, 0); @@ -713,8 +755,8 @@ TEST(fuzzy_resolve_nonexistent) { TEST(fuzzy_resolve_multiple_best_by_distance) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Process", "svcA.handlers.Process", "Function"); - cbm_registry_add(r, "Process", "svcB.handlers.Process", "Function"); + cbm_registry_add(r, "Process", "svcA.handlers.Process", "Function", false); + cbm_registry_add(r, "Process", "svcB.handlers.Process", "Function", false); /* Caller in svcA — should prefer svcA */ cbm_fuzzy_result_t fr = @@ -733,7 +775,7 @@ TEST(fuzzy_resolve_multiple_best_by_distance) { TEST(fuzzy_resolve_deep_name_extraction) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "DoWork", "myproject.utils.DoWork", "Function"); + cbm_registry_add(r, "DoWork", "myproject.utils.DoWork", "Function", false); /* Deeply qualified callee — should extract "DoWork" */ cbm_fuzzy_result_t fr = @@ -758,7 +800,7 @@ TEST(fuzzy_resolve_empty_registry) { TEST(fuzzy_resolve_confidence_single) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Handler", "proj.svc.Handler", "Function"); + cbm_registry_add(r, "Handler", "proj.svc.Handler", "Function", false); cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(r, "unknownPkg.Handler", "proj.caller", NULL, NULL, 0); @@ -772,8 +814,8 @@ TEST(fuzzy_resolve_confidence_single) { TEST(fuzzy_resolve_confidence_distance) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Process", "proj.svcA.Process", "Function"); - cbm_registry_add(r, "Process", "proj.svcB.Process", "Function"); + cbm_registry_add(r, "Process", "proj.svcA.Process", "Function", false); + cbm_registry_add(r, "Process", "proj.svcB.Process", "Function", false); cbm_fuzzy_result_t fr = cbm_registry_fuzzy_resolve(r, "unknownPkg.Process", "proj.svcA.other", NULL, NULL, 0); @@ -787,7 +829,7 @@ TEST(fuzzy_resolve_confidence_distance) { TEST(fuzzy_penalty_unreachable_import) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Handler", "proj.billing.Handler", "Function"); + cbm_registry_add(r, "Handler", "proj.billing.Handler", "Function", false); /* Import for different module → confidence halved */ const char *keys[] = {"other"}; @@ -804,7 +846,7 @@ TEST(fuzzy_penalty_unreachable_import) { TEST(fuzzy_no_import_map_passthrough) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "Handler", "proj.billing.Handler", "Function"); + cbm_registry_add(r, "Handler", "proj.billing.Handler", "Function", false); /* nil import map → full confidence */ cbm_fuzzy_result_t fr = @@ -913,8 +955,8 @@ TEST(registry_tie_break_is_independent_of_registration_order) { cbm_registry_t *forward = cbm_registry_new(); cbm_registry_t *backward = cbm_registry_new(); for (int i = 0; i < n; i++) { - cbm_registry_add(forward, "dev_name", cands[i], labels[i]); - cbm_registry_add(backward, "dev_name", cands[n - 1 - i], labels[n - 1 - i]); + cbm_registry_add(forward, "dev_name", cands[i], labels[i], false); + cbm_registry_add(backward, "dev_name", cands[n - 1 - i], labels[n - 1 - i], false); } /* A far-away caller with no imports: every candidate scores the same. */ @@ -932,10 +974,10 @@ TEST(registry_tie_break_is_independent_of_registration_order) { /* Equal depth: the smaller QN, from either order. */ cbm_registry_t *lex_a = cbm_registry_new(); cbm_registry_t *lex_b = cbm_registry_new(); - cbm_registry_add(lex_a, "sg_next", "proj.tools.virtio.scatterlist.sg_next", "Function"); - cbm_registry_add(lex_a, "sg_next", "proj.include.linux.scatterlist.sg_next", "Function"); - cbm_registry_add(lex_b, "sg_next", "proj.include.linux.scatterlist.sg_next", "Function"); - cbm_registry_add(lex_b, "sg_next", "proj.tools.virtio.scatterlist.sg_next", "Function"); + cbm_registry_add(lex_a, "sg_next", "proj.tools.virtio.scatterlist.sg_next", "Function", false); + cbm_registry_add(lex_a, "sg_next", "proj.include.linux.scatterlist.sg_next", "Function", false); + cbm_registry_add(lex_b, "sg_next", "proj.include.linux.scatterlist.sg_next", "Function", false); + cbm_registry_add(lex_b, "sg_next", "proj.tools.virtio.scatterlist.sg_next", "Function", false); cbm_resolution_t la = cbm_registry_resolve(lex_a, "sg_next", "proj.drivers.scsi.arm_scsi", NULL, NULL, 0); cbm_resolution_t lb = @@ -945,9 +987,9 @@ TEST(registry_tie_break_is_independent_of_registration_order) { /* Proximity still outranks depth: the sibling wins over a shallower stranger. */ cbm_registry_t *near = cbm_registry_new(); - cbm_registry_add(near, "vnic_rq_free", "proj.lib.vnic_rq_free", "Function"); + cbm_registry_add(near, "vnic_rq_free", "proj.lib.vnic_rq_free", "Function", false); cbm_registry_add(near, "vnic_rq_free", "proj.drivers.scsi.fnic.vnic_rq.vnic_rq_free", - "Function"); + "Function", false); cbm_resolution_t nr = cbm_registry_resolve(near, "vnic_rq_free", "proj.drivers.scsi.fnic.fnic_res", NULL, NULL, 0); ASSERT_STR_EQ(nr.qualified_name, "proj.drivers.scsi.fnic.vnic_rq.vnic_rq_free"); @@ -1209,8 +1251,8 @@ TEST(weak_call_guards_share_one_drop_list) { * Signal.send). Regression guard for #1000. */ TEST(resolve_import_map_alias_with_suffix_hits_method) { cbm_registry_t *r = cbm_registry_new(); - cbm_registry_add(r, "user_logged_in", "proj.auth.signals.user_logged_in", "Variable"); - cbm_registry_add(r, "send", "proj.auth.signals.user_logged_in.send", "Method"); + cbm_registry_add(r, "user_logged_in", "proj.auth.signals.user_logged_in", "Variable", false); + cbm_registry_add(r, "send", "proj.auth.signals.user_logged_in.send", "Method", false); const char *keys[] = {"user_logged_in"}; const char *vals[] = {"proj.auth.signals.user_logged_in"}; cbm_resolution_t res = @@ -1244,6 +1286,9 @@ SUITE(registry) { RUN_TEST(registry_create_free); RUN_TEST(registry_free_null); RUN_TEST(registry_add_and_exists); + RUN_TEST(registry_prefers_non_test_over_test_candidate); + RUN_TEST(registry_test_verdict_comes_from_the_caller_not_the_qn); + RUN_TEST(registry_sole_test_candidate_still_resolves); RUN_TEST(registry_label_of); RUN_TEST(registry_find_by_name); RUN_TEST(registry_no_duplicates); From 1ebd1e40fe715ac9efb328b2c7cebd2c0a4e140f Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Tue, 29 Sep 2026 07:48:17 +0000 Subject: [PATCH 04/16] style: clang-format the is_csharp resolve-call additions Column-limit re-wrap only, no token changes. Reproduced the CI lint failure with clang-format 18/20 (BasedOnStyle: LLVM, ColumnLimit: 100) against the .clang-format the repo's own lint.sh uses. Signed-off-by: Amir Fathi --- src/pipeline/pass_calls.c | 6 +++--- src/pipeline/pass_parallel.c | 8 +++++--- src/pipeline/pass_usages.c | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 597cd0088..d89821bd0 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -231,9 +231,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca esc_fa); cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props); if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') { - cbm_resolution_t hres = cbm_registry_resolve_lang(ctx->registry, call->second_arg_name, - module_qn, imp_keys, imp_vals, imp_count, - is_csharp); + cbm_resolution_t hres = + cbm_registry_resolve_lang(ctx->registry, call->second_arg_name, module_qn, imp_keys, + imp_vals, imp_count, is_csharp); if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') { const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name); if (handler != NULL) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index d603703ac..20c3a1f30 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3125,7 +3125,7 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, ? cbm_registry_resolve_lineage(rc->registry, usage->ref_name, module_qn, imp_keys, imp_vals, imp_count) : cbm_registry_resolve_lang(rc->registry, usage->ref_name, module_qn, imp_keys, - imp_vals, imp_count, lang == CBM_LANG_CSHARP); + imp_vals, imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } @@ -3284,14 +3284,16 @@ static void resolve_def_decorators(resolve_ctx_t *rc, resolve_worker_state_t *ws if (fn[0] == '\0') { continue; } - cbm_resolution_t res = cbm_registry_resolve_lang(rc->registry, fn, mq, ik, iv, ic, is_csharp); + cbm_resolution_t res = + cbm_registry_resolve_lang(rc->registry, fn, mq, ik, iv, ic, is_csharp); if ((!res.qualified_name || res.qualified_name[0] == '\0') && !strchr(fn, '.')) { /* C# attributes are referenced by their short name (`[Log]`) but * declared with an `Attribute` suffix (`class LogAttribute`). */ char with_suffix[CBM_SZ_256]; int wn = snprintf(with_suffix, sizeof(with_suffix), "%sAttribute", fn); if (wn > 0 && (size_t)wn < sizeof(with_suffix)) { - res = cbm_registry_resolve_lang(rc->registry, with_suffix, mq, ik, iv, ic, is_csharp); + res = + cbm_registry_resolve_lang(rc->registry, with_suffix, mq, ik, iv, ic, is_csharp); } } const cbm_gbuf_node_t *dn = NULL; diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index af99038a1..15ee73952 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -201,7 +201,7 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res ? cbm_registry_resolve_lineage(ctx->registry, usage->ref_name, module_qn, imp_keys, imp_vals, imp_count) : cbm_registry_resolve_lang(ctx->registry, usage->ref_name, module_qn, imp_keys, - imp_vals, imp_count, lang == CBM_LANG_CSHARP); + imp_vals, imp_count, lang == CBM_LANG_CSHARP); if (!res.qualified_name || res.qualified_name[0] == '\0') { continue; } From 9545ea4febde8474a4bab03cb35fcb7ab8f92783 Mon Sep 17 00:00:00 2001 From: Brian McNaboe Date: Thu, 20 Aug 2026 12:22:24 -0400 Subject: [PATCH 05/16] perf(daemon): hash the executable image once per process; announce missed hook deadlines The build-identity fingerprint is a SHA-256 over the entire ~295 MB executable: ~1.1 s per hash on an M5 Pro, ~2.3 s on the machine where this was first measured. A daemon pays it twice at start: the supervisor's startup capture and the runtime service's active-image check. - Cache the fingerprint per process at runtime_process_image_reference_acquire, keyed by (device, inode, size, mtime, change time): the identity the acquire path already verifies as stable across the hash, so a rebuilt or replaced binary rolls the key. On Windows the change time is FILE_BASIC_INFO.ChangeTime, which the kernel maintains and SetFileTime cannot reset. Every hit is still bracketed by the same before/after stat and process-maps checks, so a cached digest stays bound to the verified image. A freshly hashed digest is committed only after the acquire chain, including the post-hash stat check, has passed, and only under the identity observed after the hash, so an image that changed under the hash is never cached. The cache is process-local: it removes the daemon's second start-up hash (cold start 6.5 s -> 5.3 s with a 296 MB image); a fresh hook process still hashes its own image once. - A missed hook deadline is announced on stderr as well as the timeouts log, so a fired deadline is never a silent 0-byte exit 0. The breadcrumb is formatted before the log path is resolved, so stderr gets it even when the log cannot be opened. CBM_HOOK_DEADLINE_MS is unchanged. SHA-256 itself is unchanged: one portable scalar implementation on every platform. Refs #1335, #2058 Signed-off-by: Brian McNaboe --- src/cli/hook_augment.c | 31 ++-- src/daemon/runtime.c | 355 +++++++++++++++++++++++++++++++++++- src/daemon/runtime.h | 25 +++ tests/test_cli.c | 34 +++- tests/test_daemon_runtime.c | 134 ++++++++++++++ 5 files changed, 556 insertions(+), 23 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c365..b5541fcb9 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -96,14 +96,31 @@ static size_t g_ha_crumb_len = 0; static void ha_deadline_exit(int sig) { (void)sig; - if (g_ha_crumb_fd >= 0 && g_ha_crumb_len > 0) { - ssize_t w = write(g_ha_crumb_fd, g_ha_crumb_msg, g_ha_crumb_len); - (void)w; + /* async-signal-safe only: write() the prepared breadcrumb to the timeouts + * log (when it opened) and ALWAYS to stderr, so a fired deadline is a + * visible event, never a silent 0-byte exit(0). */ + if (g_ha_crumb_len > 0) { + if (g_ha_crumb_fd >= 0) { + ssize_t w = write(g_ha_crumb_fd, g_ha_crumb_msg, g_ha_crumb_len); + (void)w; + } + ssize_t e = write(STDERR_FILENO, g_ha_crumb_msg, g_ha_crumb_len); + (void)e; } _exit(0); } static void ha_open_crumb_log(int deadline_ms) { + /* Pre-format the breadcrumb unconditionally: the handler may only write() a + * prepared buffer, and formatting it before the log path is resolved means + * stderr still gets the line even when the file cannot be opened (no HOME, + * unwritable cache dir) — the "never silent" guarantee holds regardless. */ + int n = snprintf(g_ha_crumb_msg, sizeof(g_ha_crumb_msg), + "hook-augment: deadline_exceeded ms=%d pid=%ld (raise via " + "CBM_HOOK_DEADLINE_MS)\n", + deadline_ms, (long)getpid()); + g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0; + const char *override = getenv("CBM_HOOK_TIMEOUT_LOG"); /* tests + power users */ char path[CBM_SZ_1K]; if (override && override[0]) { @@ -119,14 +136,6 @@ static void ha_open_crumb_log(int deadline_ms) { snprintf(path, sizeof(path), "%s/hook-augment-timeouts.log", dir); } g_ha_crumb_fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0644); - if (g_ha_crumb_fd < 0) { - return; - } - int n = snprintf(g_ha_crumb_msg, sizeof(g_ha_crumb_msg), - "hook-augment: deadline_exceeded ms=%d pid=%ld (raise via " - "CBM_HOOK_DEADLINE_MS)\n", - deadline_ms, (long)getpid()); - g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0; } int cbm_hook_augment_deadline_ms_for_testing(void) { diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 8d1d8af94..de8690494 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -752,6 +752,313 @@ static bool runtime_posix_stat_same_image(const struct stat *first, const struct #endif +/* ── Executable-image fingerprint cache ────────────────────────────────────── + * The build-identity fingerprint is a SHA-256 over the entire executable + * (cbm_daemon_build_fingerprint_native_file): about a second of CPU per hash + * for a ~300 MB image. A daemon process otherwise pays it twice at start (the + * supervisor's startup capture, then the runtime service's active-image check), + * so the digest is cached per process, keyed by the file identity the acquire + * path verifies as stable across the hash: device, inode, size, mtime and + * change time (on Windows FILE_BASIC_INFO.ChangeTime, which the kernel + * maintains and SetFileTime cannot reset, unlike the creation time). A rebuilt + * or replaced binary rolls that key and misses, so the cache never returns a + * stale digest and build-cohort admission keeps its exact meaning. The cache is + * process-local: a fresh process (a hook, a CLI call) still hashes its own image + * once. A hit does not weaken the identity proof: the acquire chain still + * brackets the held image with its before/after stat and process-maps checks, + * so the returned digest is bound to that same verified image; only the re-read + * of bytes whose identity is unchanged is skipped. A freshly hashed digest is + * committed only after that post-hash check has passed, under the identity + * observed after the hash, so an image that changed under the hash is never + * cached. */ +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || \ + defined(__NetBSD__) + +/* POSIX carries timespec fields; Windows stores FILETIME ticks in the seconds + * fields and leaves the nanoseconds fields zero. */ +typedef struct { + bool valid; + uint64_t device; + uint64_t inode; + uint64_t size; + int64_t mtime_seconds; + int64_t mtime_nanoseconds; + int64_t ctime_seconds; + int64_t ctime_nanoseconds; +} runtime_fingerprint_key_t; + +#define RUNTIME_FINGERPRINT_CACHE_CAP 8 + +static atomic_flag runtime_fingerprint_cache_lock = ATOMIC_FLAG_INIT; +static struct { + runtime_fingerprint_key_t key; + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +} runtime_fingerprint_cache[RUNTIME_FINGERPRINT_CACHE_CAP]; +static size_t runtime_fingerprint_cache_count; +static size_t runtime_fingerprint_cache_victim; + +#ifdef CBM_ENABLE_TEST_SEAMS +static atomic_int runtime_fingerprint_hash_call_count; +static atomic_bool runtime_fingerprint_hash_stub_active; +static char runtime_fingerprint_hash_stub_digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +#endif + +static bool runtime_fingerprint_key_equal(const runtime_fingerprint_key_t *a, + const runtime_fingerprint_key_t *b) { + return a->valid && b->valid && a->device == b->device && a->inode == b->inode && + a->size == b->size && a->mtime_seconds == b->mtime_seconds && + a->mtime_nanoseconds == b->mtime_nanoseconds && a->ctime_seconds == b->ctime_seconds && + a->ctime_nanoseconds == b->ctime_nanoseconds; +} + +static void runtime_fingerprint_cache_enter(void) { + while ( + atomic_flag_test_and_set_explicit(&runtime_fingerprint_cache_lock, memory_order_acquire)) {} +} + +static void runtime_fingerprint_cache_leave(void) { + atomic_flag_clear_explicit(&runtime_fingerprint_cache_lock, memory_order_release); +} + +static bool runtime_fingerprint_cache_lookup(const runtime_fingerprint_key_t *key, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!key->valid) { + return false; + } + bool hit = false; + runtime_fingerprint_cache_enter(); + for (size_t index = 0; index < runtime_fingerprint_cache_count; index++) { + if (runtime_fingerprint_key_equal(&runtime_fingerprint_cache[index].key, key)) { + memcpy(out, runtime_fingerprint_cache[index].fingerprint, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + hit = true; + break; + } + } + runtime_fingerprint_cache_leave(); + return hit; +} + +static void runtime_fingerprint_cache_store( + const runtime_fingerprint_key_t *key, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!key->valid || fingerprint[0] == '\0') { + return; + } + runtime_fingerprint_cache_enter(); + size_t slot = RUNTIME_FINGERPRINT_CACHE_CAP; + for (size_t index = 0; index < runtime_fingerprint_cache_count; index++) { + if (runtime_fingerprint_key_equal(&runtime_fingerprint_cache[index].key, key)) { + slot = index; + break; + } + } + if (slot == RUNTIME_FINGERPRINT_CACHE_CAP) { + if (runtime_fingerprint_cache_count < RUNTIME_FINGERPRINT_CACHE_CAP) { + slot = runtime_fingerprint_cache_count++; + } else { + slot = runtime_fingerprint_cache_victim; + runtime_fingerprint_cache_victim = + (runtime_fingerprint_cache_victim + 1) % RUNTIME_FINGERPRINT_CACHE_CAP; + } + } + runtime_fingerprint_cache[slot].key = *key; + memcpy(runtime_fingerprint_cache[slot].fingerprint, fingerprint, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + runtime_fingerprint_cache_leave(); +} + +/* The single point where the expensive image hash is paid; a test seam counts + * calls and can substitute a stub digest so the cache's hit/miss behaviour is + * observable without a real full-image read. */ +static bool runtime_fingerprint_hash_native_file(uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +#ifdef CBM_ENABLE_TEST_SEAMS + atomic_fetch_add_explicit(&runtime_fingerprint_hash_call_count, 1, memory_order_relaxed); + if (atomic_load_explicit(&runtime_fingerprint_hash_stub_active, memory_order_acquire)) { + memcpy(out, runtime_fingerprint_hash_stub_digest, CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + return true; + } +#endif + return cbm_daemon_build_fingerprint_native_file(native_file, out); +} + +/* Resolve the digest for the image identity `key`: from the cache when it + * already holds that identity, otherwise by hashing the held native image. + * `cached` reports which. A freshly hashed digest is deliberately not stored + * here: the acquire site commits it (runtime_fingerprint_cache_commit) only + * once its post-hash identity check has proven the hashed bytes belong to the + * identity they were keyed on. */ +static bool runtime_build_fingerprint_resolve(uintptr_t native_file, + const runtime_fingerprint_key_t *key, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], + bool *cached) { + *cached = runtime_fingerprint_cache_lookup(key, fingerprint); + return *cached || runtime_fingerprint_hash_native_file(native_file, fingerprint); +} + +/* Commit a freshly hashed digest after the acquire chain has passed. The + * identity observed after the hash must equal the one the digest was resolved + * under; otherwise the bytes read belong to no single identity and are not + * cached. A cache hit has nothing to commit. */ +static void runtime_fingerprint_cache_commit( + const runtime_fingerprint_key_t *before, const runtime_fingerprint_key_t *after, bool cached, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!cached && runtime_fingerprint_key_equal(before, after)) { + runtime_fingerprint_cache_store(after, fingerprint); + } +} + +#ifdef _WIN32 +static int64_t runtime_fingerprint_filetime(FILETIME value) { + return (int64_t)(((uint64_t)value.dwHighDateTime << 32) | value.dwLowDateTime); +} + +/* Key the held image by the snapshot identity the acquire path compares plus + * the kernel-maintained change time. An unreadable change time yields an + * invalid key, which never hits and is never stored: the image is simply + * hashed. */ +static void runtime_fingerprint_key_windows(HANDLE file, + const BY_HANDLE_FILE_INFORMATION *information, + const LARGE_INTEGER *size, + runtime_fingerprint_key_t *key) { + memset(key, 0, sizeof(*key)); + FILE_BASIC_INFO basic; + memset(&basic, 0, sizeof(basic)); + key->valid = information != NULL && size != NULL && size->QuadPart >= 0 && + file != INVALID_HANDLE_VALUE && + GetFileInformationByHandleEx(file, FileBasicInfo, &basic, sizeof(basic)) != 0; + if (key->valid) { + key->device = information->dwVolumeSerialNumber; + key->inode = ((uint64_t)information->nFileIndexHigh << 32) | information->nFileIndexLow; + key->size = (uint64_t)size->QuadPart; + key->mtime_seconds = runtime_fingerprint_filetime(information->ftLastWriteTime); + key->ctime_seconds = basic.ChangeTime.QuadPart; + } +} + +static bool runtime_build_fingerprint_resolve_windows( + HANDLE file, const BY_HANDLE_FILE_INFORMATION *information, const LARGE_INTEGER *size, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], runtime_fingerprint_key_t *key, + bool *cached) { + runtime_fingerprint_key_windows(file, information, size, key); + return runtime_build_fingerprint_resolve((uintptr_t)file, key, fingerprint, cached); +} + +static void runtime_fingerprint_cache_commit_windows( + HANDLE file, const BY_HANDLE_FILE_INFORMATION *information, const LARGE_INTEGER *size, + const runtime_fingerprint_key_t *key, bool cached, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key_after; + runtime_fingerprint_key_windows(file, information, size, &key_after); + runtime_fingerprint_cache_commit(key, &key_after, cached, fingerprint); +} +#else +static void runtime_fingerprint_key_posix(const struct stat *status, + runtime_fingerprint_key_t *key) { + memset(key, 0, sizeof(*key)); + key->valid = status != NULL && S_ISREG(status->st_mode); + if (key->valid) { + key->device = (uint64_t)status->st_dev; + key->inode = (uint64_t)status->st_ino; + key->size = (uint64_t)status->st_size; +#ifdef __APPLE__ + key->mtime_seconds = status->st_mtimespec.tv_sec; + key->mtime_nanoseconds = status->st_mtimespec.tv_nsec; + key->ctime_seconds = status->st_ctimespec.tv_sec; + key->ctime_nanoseconds = status->st_ctimespec.tv_nsec; +#else + key->mtime_seconds = status->st_mtim.tv_sec; + key->mtime_nanoseconds = status->st_mtim.tv_nsec; + key->ctime_seconds = status->st_ctim.tv_sec; + key->ctime_nanoseconds = status->st_ctim.tv_nsec; +#endif + } +} + +static bool runtime_build_fingerprint_resolve_posix( + uintptr_t native_file, const struct stat *status, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], runtime_fingerprint_key_t *key, + bool *cached) { + runtime_fingerprint_key_posix(status, key); + return runtime_build_fingerprint_resolve(native_file, key, fingerprint, cached); +} + +static void runtime_fingerprint_cache_commit_posix( + const struct stat *status, const runtime_fingerprint_key_t *key, bool cached, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key_after; + runtime_fingerprint_key_posix(status, &key_after); + runtime_fingerprint_cache_commit(key, &key_after, cached, fingerprint); +} +#endif + +#ifdef CBM_ENABLE_TEST_SEAMS +void cbm_daemon_runtime_fingerprint_cache_reset_for_testing(void) { + runtime_fingerprint_cache_enter(); + memset(runtime_fingerprint_cache, 0, sizeof(runtime_fingerprint_cache)); + runtime_fingerprint_cache_count = 0; + runtime_fingerprint_cache_victim = 0; + runtime_fingerprint_cache_leave(); + atomic_store_explicit(&runtime_fingerprint_hash_call_count, 0, memory_order_relaxed); + atomic_store_explicit(&runtime_fingerprint_hash_stub_active, false, memory_order_release); + memset(runtime_fingerprint_hash_stub_digest, 0, sizeof(runtime_fingerprint_hash_stub_digest)); +} + +void cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(const char *digest) { + if (!digest) { + atomic_store_explicit(&runtime_fingerprint_hash_stub_active, false, memory_order_release); + return; + } + memset(runtime_fingerprint_hash_stub_digest, 0, sizeof(runtime_fingerprint_hash_stub_digest)); + size_t length = strlen(digest); + if (length >= CBM_DAEMON_BUILD_FINGERPRINT_SIZE) { + length = CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1; + } + memcpy(runtime_fingerprint_hash_stub_digest, digest, length); + atomic_store_explicit(&runtime_fingerprint_hash_stub_active, true, memory_order_release); +} + +int cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(void) { + return atomic_load_explicit(&runtime_fingerprint_hash_call_count, memory_order_relaxed); +} + +static runtime_fingerprint_key_t runtime_fingerprint_key_for_testing( + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *identity) { + runtime_fingerprint_key_t key = {.valid = true, + .device = identity->device, + .inode = identity->inode, + .size = identity->size, + .mtime_seconds = identity->mtime_seconds, + .mtime_nanoseconds = identity->mtime_nanoseconds, + .ctime_seconds = identity->ctime_seconds, + .ctime_nanoseconds = identity->ctime_nanoseconds}; + return key; +} + +bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *before, + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *after, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + /* There is no native image behind a synthetic identity: only the stubbed + * hash may ever be reached from here. */ + if (!before || !after || !out || + !atomic_load_explicit(&runtime_fingerprint_hash_stub_active, memory_order_acquire)) { + return false; + } + runtime_fingerprint_key_t key_before = runtime_fingerprint_key_for_testing(before); + runtime_fingerprint_key_t key_after = runtime_fingerprint_key_for_testing(after); + bool cached = false; + if (!runtime_build_fingerprint_resolve((uintptr_t)0, &key_before, out, &cached)) { + return false; + } + runtime_fingerprint_cache_commit(&key_before, &key_after, cached, out); + return true; +} +#endif + +#endif /* supported platform: executable-image fingerprint cache */ + /* Acquire one process instance's mapped image as a native object. Supplying a * fingerprint hashes that same held object; NULL performs metadata-only * acquisition for the HELLO fast path. Every platform brackets the process @@ -780,6 +1087,8 @@ static bool runtime_process_image_reference_acquire( BY_HANDLE_FILE_INFORMATION file_after; LARGE_INTEGER size_before; LARGE_INTEGER size_after; + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; bool ok = runtime_windows_process_image_snapshot(process, &process_before); /* QueryFullProcessImageNameW returns a stable identity spelling for the * before/after comparison, but its Win32/DOS form may exceed MAX_PATH. @@ -793,7 +1102,9 @@ static bool runtime_process_image_reference_acquire( : INVALID_HANDLE_VALUE; free(open_path); ok = ok && runtime_windows_file_snapshot(file, &file_before, &size_before) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file((uintptr_t)file, fingerprint)) && + (!fingerprint || + runtime_build_fingerprint_resolve_windows(file, &file_before, &size_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && runtime_windows_file_snapshot(file, &file_after, &size_after) && runtime_windows_file_snapshot_same(&file_before, &size_before, &file_after, &size_after) && runtime_windows_process_image_snapshot(process, &process_after) && @@ -802,6 +1113,10 @@ static bool runtime_process_image_reference_acquire( ok = false; } if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_windows( + file, &file_after, &size_after, &fingerprint_key, fingerprint_cached, fingerprint); + } reference->held = true; reference->file = file; reference->information = file_after; @@ -827,14 +1142,22 @@ static bool runtime_process_image_reference_acquire( int fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) : -1; struct stat file_before; struct stat file_after; + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; ok = ok && fd >= 0 && fstat(fd, &file_before) == 0 && S_ISREG(file_before.st_mode) && runtime_mac_process_maps_file_executable(pid, &file_before) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file((uintptr_t)fd, fingerprint)) && + (!fingerprint || + runtime_build_fingerprint_resolve_posix((uintptr_t)fd, &file_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && fstat(fd, &file_after) == 0 && runtime_mac_stat_same(&file_before, &file_after) && runtime_mac_process_maps_file_executable(pid, &file_after) && runtime_mac_process_instance(pid, &process_after) && runtime_mac_process_instance_same(&process_before, &process_after); if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_posix(&file_after, &fingerprint_key, + fingerprint_cached, fingerprint); + } reference->held = true; reference->fd = fd; reference->status = file_after; @@ -852,12 +1175,15 @@ static bool runtime_process_image_reference_acquire( int image_fd = process_fd >= 0 ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; struct stat image_before; struct stat image_after; - bool ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && - S_ISREG(image_before.st_mode) && - (!fingerprint || - cbm_daemon_build_fingerprint_native_file((uintptr_t)image_fd, fingerprint)) && - fstat(image_fd, &image_after) == 0 && - runtime_posix_stat_same_image(&image_before, &image_after); + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; + bool ok = + image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && + (!fingerprint || + runtime_build_fingerprint_resolve_posix((uintptr_t)image_fd, &image_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && + fstat(image_fd, &image_after) == 0 && + runtime_posix_stat_same_image(&image_before, &image_after); int verify_fd = ok ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; struct stat verify_status; ok = ok && verify_fd >= 0 && fstat(verify_fd, &verify_status) == 0 && @@ -869,6 +1195,10 @@ static bool runtime_process_image_reference_acquire( ok = false; } if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_posix(&image_after, &fingerprint_key, + fingerprint_cached, fingerprint); + } reference->held = true; reference->fd = image_fd; reference->status = image_after; @@ -894,12 +1224,19 @@ static bool runtime_process_image_reference_acquire( int image_fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) : -1; struct stat image_before; struct stat image_after; + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && (!fingerprint || - cbm_daemon_build_fingerprint_native_file((uintptr_t)image_fd, fingerprint)) && + runtime_build_fingerprint_resolve_posix((uintptr_t)image_fd, &image_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && fstat(image_fd, &image_after) == 0 && runtime_posix_stat_same_image(&image_before, &image_after); if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_posix(&image_after, &fingerprint_key, + fingerprint_cached, fingerprint); + } reference->held = true; reference->fd = image_fd; reference->status = image_after; diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index d9c3db8e9..deb1fed10 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -465,6 +465,31 @@ void cbm_daemon_runtime_set_containment_hook_for_testing( * in test time. UINT32_MAX restores the production constant; any other value * (0 = expire immediately) overrides. Process-global; reset it after use. */ void cbm_daemon_runtime_service_set_ephemeral_linger_timeout_for_testing(uint32_t timeout_ms); + +/* Executable-image fingerprint-cache seams. reset empties the cache, zeroes + * the hash counter and disarms the stub; a test that arms the stub must reset + * before its first assertion, because an armed stub replaces every later + * fingerprint in the process. resolve drives a synthetic file identity through + * the exact resolve-then-commit path the acquire sites use: `before` is the + * identity the digest is keyed on, `after` the identity observed after the + * hash, and the digest is committed only when the two agree. It refuses unless + * the stub is armed, since no native image backs a synthetic identity. */ +typedef struct { + uint64_t device; + uint64_t inode; + uint64_t size; + int64_t mtime_seconds; + int64_t mtime_nanoseconds; + int64_t ctime_seconds; + int64_t ctime_nanoseconds; +} cbm_daemon_runtime_fingerprint_identity_for_testing_t; +void cbm_daemon_runtime_fingerprint_cache_reset_for_testing(void); +void cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(const char *digest); +int cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(void); +bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *before, + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *after, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); #endif #endif /* CBM_DAEMON_RUNTIME_H */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 7cd44244e..e20a881c0 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14656,19 +14656,31 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { snprintf(logpath, sizeof(logpath), "%s/timeouts.log", tmpdir); int fds[2]; + int errfds[2]; if (pipe(fds) != 0) { test_rmdir_r(tmpdir); FAIL("pipe failed"); } + if (pipe(errfds) != 0) { + close(fds[0]); + close(fds[1]); + test_rmdir_r(tmpdir); + FAIL("stderr pipe failed"); + } fflush(NULL); pid_t pid = fork(); if (pid == 0) { /* Child: hook-augment with a 60ms deadline and stdin that blocks - * forever (parent keeps the write end open, sends nothing). */ + * forever (parent keeps the write end open, sends nothing). Its stderr + * is redirected so the parent can prove the deadline is also announced + * there, not only in the timeouts log. */ close(fds[1]); dup2(fds[0], 0); close(fds[0]); + close(errfds[0]); + dup2(errfds[1], 2); + close(errfds[1]); setenv("CBM_HOOK_DEADLINE_MS", "60", 1); setenv("CBM_HOOK_TIMEOUT_LOG", logpath, 1); alarm(10); /* backstop: never hang the suite */ @@ -14676,6 +14688,19 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { } ASSERT_GT(pid, 0); close(fds[0]); + close(errfds[1]); + + /* Drain the child's stderr before reaping, so a full pipe can never wedge + * it. EOF arrives once the child's deadline handler has run and _exit()ed. */ + char errbuf[4096] = ""; + size_t errlen = 0; + ssize_t rd; + while (errlen + 1 < sizeof(errbuf) && + (rd = read(errfds[0], errbuf + errlen, sizeof(errbuf) - 1 - errlen)) > 0) { + errlen += (size_t)rd; + } + errbuf[errlen] = '\0'; + close(errfds[0]); int status = 0; waitpid(pid, &status, 0); @@ -14686,8 +14711,8 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { ASSERT_EQ(WEXITSTATUS(status), 0); /* RED before the fix: no breadcrumb existed — a fired deadline was - * indistinguishable from a no-match run. GREEN: the log names the - * deadline and the knob. */ + * indistinguishable from a no-match run. GREEN: it is announced on BOTH + * the timeouts log and stderr, each naming the deadline and the knob. */ FILE *f = fopen(logpath, "r"); if (!f) { fprintf(stderr, " [858] FAIL no timeout breadcrumb written to %s\n", logpath); @@ -14700,6 +14725,9 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { ASSERT(strstr(line, "deadline_exceeded") != NULL); ASSERT(strstr(line, "CBM_HOOK_DEADLINE_MS") != NULL); + ASSERT(strstr(errbuf, "deadline_exceeded") != NULL); + ASSERT(strstr(errbuf, "CBM_HOOK_DEADLINE_MS") != NULL); + cbm_unsetenv("CBM_HOOK_DEADLINE_MS"); cbm_unsetenv("CBM_HOOK_TIMEOUT_LOG"); test_rmdir_r(tmpdir); diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 8bfb611bc..49ca1f894 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -4920,6 +4920,137 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { } #endif +/* The executable-image fingerprint cache short-circuits the full-image hash for + * a repeated file identity, and rolls its key when the binary changes so a + * rebuild or atomic replacement never returns a stale digest. Results are + * gathered first and asserted only after the stub is disarmed: an assertion + * returns early, and an armed stub would replace every later fingerprint in + * this process. */ +TEST(daemon_runtime_fingerprint_cache_hit_miss_key_roll) { + /* 64-hex placeholders, distinct enough to tell "cached" from "recomputed". */ + static const char digest_a[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + static const char digest_b[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const cbm_daemon_runtime_fingerprint_identity_for_testing_t image = {1, 2, 3, 100, + 200, 300, 400}; + cbm_daemon_runtime_fingerprint_identity_for_testing_t rolled = image; + char miss[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char hit[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char mtime_rolled[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char ctime_rolled[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char inode_rolled[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + /* Miss: first sight of this identity pays the hash and stores digest_a. */ + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest_a); + bool miss_ok = cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &image, miss); + int hashes_after_miss = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* Hit: the same identity returns the cached digest without calling the + * hash, even though the stub would now yield a different value. */ + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest_b); + bool hit_ok = cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &image, hit); + int hashes_after_hit = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* Key roll: a changed mtime (a rebuilt binary) misses and recomputes. */ + rolled = image; + rolled.mtime_seconds = 101; + bool mtime_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&rolled, &rolled, mtime_rolled); + int hashes_after_mtime_roll = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* A changed change time (an in-place write, a metadata edit) rolls it too. */ + rolled = image; + rolled.ctime_seconds = 301; + bool ctime_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&rolled, &rolled, ctime_rolled); + int hashes_after_ctime_roll = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* A changed inode (atomic replacement) likewise rolls the key. */ + rolled = image; + rolled.inode = 9; + bool inode_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&rolled, &rolled, inode_rolled); + int hashes_after_inode_roll = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + + ASSERT_TRUE(miss_ok); + ASSERT_STR_EQ(miss, digest_a); + ASSERT_EQ(hashes_after_miss, 1); + ASSERT_TRUE(hit_ok); + ASSERT_STR_EQ(hit, digest_a); + ASSERT_EQ(hashes_after_hit, 1); + ASSERT_TRUE(mtime_ok); + ASSERT_STR_EQ(mtime_rolled, digest_b); + ASSERT_EQ(hashes_after_mtime_roll, 2); + ASSERT_TRUE(ctime_ok); + ASSERT_STR_EQ(ctime_rolled, digest_b); + ASSERT_EQ(hashes_after_ctime_roll, 3); + ASSERT_TRUE(inode_ok); + ASSERT_STR_EQ(inode_rolled, digest_b); + ASSERT_EQ(hashes_after_inode_roll, 4); + PASS(); +} + +/* A digest is committed only when the identity observed after the hash is the + * one it was keyed on. An image that changed under the hash still yields the + * bytes read for that acquisition (the acquire chain's own post-hash stat check + * is what rejects it) but is never cached, so the next sight of the original + * identity hashes again. */ +TEST(daemon_runtime_fingerprint_cache_skips_image_changed_under_hash) { + static const char digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const cbm_daemon_runtime_fingerprint_identity_for_testing_t image = {1, 2, 3, 100, + 200, 300, 400}; + cbm_daemon_runtime_fingerprint_identity_for_testing_t moved = image; + moved.ctime_seconds = 301; + char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest); + bool first_ok = cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &moved, first); + int hashes_after_first = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + bool second_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &image, second); + int hashes_after_second = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + + ASSERT_TRUE(first_ok); + ASSERT_STR_EQ(first, digest); + ASSERT_EQ(hashes_after_first, 1); + ASSERT_TRUE(second_ok); + ASSERT_STR_EQ(second, digest); + ASSERT_EQ(hashes_after_second, 2); + PASS(); +} + +/* The real acquire path: fingerprinting this process twice pays the image hash + * once, and the second call is served from the cache inside the same bracketed + * identity checks. This drives cbm_daemon_runtime_process_build_fingerprint, + * not the seam, so a cache wired into the seam but not into the platform's + * acquire site fails here. */ +TEST(daemon_runtime_process_fingerprint_hashes_own_image_once_per_process) { + static const char digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest); + bool first_ok = cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), first); + int hashes_after_first = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + bool second_ok = + cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), second); + int hashes_after_second = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + + ASSERT_TRUE(first_ok); + ASSERT_TRUE(second_ok); + ASSERT_STR_EQ(first, digest); + ASSERT_STR_EQ(second, digest); + ASSERT_EQ(hashes_after_first, 1); + ASSERT_EQ(hashes_after_second, 1); + PASS(); +} + TEST(daemon_runtime_close_begin_releases_admission_with_inflight_request) { static const uint8_t request[] = {'b', 'l', 'o', 'c', 'k'}; cbm_daemon_build_identity_t identity = @@ -5114,6 +5245,9 @@ SUITE(daemon_runtime) { #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) RUN_TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path); #endif + RUN_TEST(daemon_runtime_fingerprint_cache_hit_miss_key_roll); + RUN_TEST(daemon_runtime_fingerprint_cache_skips_image_changed_under_hash); + RUN_TEST(daemon_runtime_process_fingerprint_hashes_own_image_once_per_process); RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); From c5784a99df9388c916967807b8952c432606b441 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:29:27 +0200 Subject: [PATCH 06/16] fix(registry): gate C# type-keyword receivers on the call's CBMLanguage Follow-up to the review of #2170, on top of the contributor's change. receiver_chain_admits() fast-admits a lower-case receiver root as a value. C# spells its built-in types as lower-case keywords, so int.TryParse skipped the chain check and could bind to any project method named TryParse (#2121). This keeps the contributor's closed keyword list and moves the gate onto the language main already passes: - cbm_registry_resolve_call_lang, the CALLS entry point both resolvers share through cbm_registry_resolve_call_scoped, hands the calling file's CBMLanguage down to receiver_chain_admits, which applies the keyword branch only for CBM_LANG_CSHARP. Every other resolve passes CBM_LANG_COUNT, so USAGE, THROWS/RAISES, READS/WRITES, INHERITS, DECORATES and route-handler lookups are unchanged; extending the gate to them is a separate change. - nint and nuint (C# 9 native-sized integers) join the list. - The per-file resolve cache records the C# bit, so a C# call and a C# reference to the same name never share one cached answer. - The three unit tests call the CALLS entry point with a real CBMLanguage (C#, Python, Go) instead of a bool. - parallel_csharp_keyword_receiver_no_name_guess_issue2121 indexes the issue's two files with the sequential and the parallel pipeline: no CALLS edge from int.TryParse or nint.TryParse to the project's TsidNode.TryParse, while TsidNode.TryParse(...) still binds. Co-authored-by: Amir Fathi Signed-off-by: Martin Vogel --- src/pipeline/pipeline_internal.h | 3 +- src/pipeline/registry.c | 90 ++++++++++++++++++++++-------- tests/test_parallel.c | 95 ++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 60 ++++++++++---------- 4 files changed, 195 insertions(+), 53 deletions(-) diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index fb5b724d6..6edcad608 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -255,7 +255,8 @@ cbm_resolution_t cbm_registry_resolve_lang(const cbm_registry_t *r, const char * CBMLanguage lang); /* The CALLS variant: as cbm_registry_resolve_lang, except that a bare C call * (no `.` / `->`) does not consider Field or Method nodes, which it can never - * call. */ + * call, and that in a C# file a built-in type keyword root (`int.TryParse`) + * names a type for the receiver chain check (#2121). */ cbm_resolution_t cbm_registry_resolve_call_lang(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count, diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 7de52a262..244de3ba5 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -442,6 +442,7 @@ typedef struct { cbm_resolution_t res; size_t scope_len; /* raw and Python-scoped requests may share one file cache */ bool skip_members; /* a C call and a C reference to the same name share it too */ + bool cs_keywords; /* a C# call and a C# reference to the same name share it too */ } resolve_cache_entry_t; static CBM_TLS CBMHashTable *_resolve_cache = NULL; @@ -1734,6 +1735,25 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal return match; } +/* True when the first segment of the dotted chain `dotted` is one of C#'s + * built-in type keywords (int, string, bool, ...; nint and nuint since C# 9). + * Closed list: these words are grammar, not a naming heuristic. */ +static bool cs_type_keyword_root(const char *dotted) { + static const char *const keywords[] = { + "bool", "byte", "char", "decimal", "double", "dynamic", "float", + "int", "long", "nint", "nuint", "object", "sbyte", "short", + "string", "uint", "ulong", "ushort", "void", + }; + const char *dot = strchr(dotted, '.'); + size_t len = dot ? (size_t)(dot - dotted) : strlen(dotted); + for (size_t i = 0; i < sizeof(keywords) / sizeof(keywords[0]); i++) { + if (strlen(keywords[i]) == len && strncmp(dotted, keywords[i], len) == 0) { + return true; + } + } + return false; +} + /* A dotted callee whose FIRST segment starts upper-case names a type — URLSession, * Calendar, JSONEncoder. That receiver chain is evidence the bare-name scorers * throw away, and throwing it away binds Foundation's URLSession.shared.data to @@ -1747,9 +1767,17 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal * show, so the chain proves nothing there and the call passes through * unchanged. A callee with no separator passes through as well. * - * Language agnostic by design: the registry holds no language, and every - * language that writes receiver chains gains the same protection. */ -static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn) { + * One exception, for C# CALLS only (#2121): C# spells its built-in types as + * lower-case keywords, so in `int.TryParse` or `string.Join` the root names a + * type and gets the same check (cs_type_keyword_root). `lang` is the calling + * file's language on the CALLS path (cbm_registry_resolve_call_lang) and + * CBM_LANG_COUNT on every other resolve. In Python, JS or Go these words are + * ordinary identifiers, so a variable named `object` stays a value root there. + * + * Otherwise language agnostic by design: every language that writes receiver + * chains gains the same protection. */ +static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn, + CBMLanguage lang) { /* Normalize "::" -> "." so the chain composes with dotted candidate QNs, * the same way qualified_suffix_match does. */ char dotted[CBM_SZ_512]; @@ -1768,7 +1796,8 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate if (!last_dot) { return true; /* bare name — no receiver chain to judge */ } - if (dotted[0] < 'A' || dotted[0] > 'Z') { + if ((dotted[0] < 'A' || dotted[0] > 'Z') && + !(lang == CBM_LANG_CSHARP && cs_type_keyword_root(dotted))) { return true; /* lower-case root names a value, not a type */ } /* A name written in capitals with underscores is a constant holding a @@ -1856,10 +1885,12 @@ static void candidates_without_members(const cbm_registry_t *r, const qn_array_t .lang = side ? lang : NULL}; } -/* Strategies 3.5, 3 and 4 over one same-name candidate set. */ +/* Strategies 3.5, 3 and 4 over one same-name candidate set. `lang` reaches + * receiver_chain_admits only (see there). */ static cbm_resolution_t resolve_name_candidates(const cbm_registry_t *r, const qn_array_t *arr, const char *callee_name, const char *module_qn, - const char **import_vals, int import_count) { + const char **import_vals, int import_count, + CBMLanguage lang) { /* Strategy 3.5: a qualified callee disambiguates among multiple same-name * candidates by full qualified tail, before bare-name scoring collapses * them onto a single winner. */ @@ -1872,7 +1903,7 @@ static cbm_resolution_t resolve_name_candidates(const cbm_registry_t *r, const q /* Strategy 3: unique name */ if (arr->count == SKIP_ONE) { - if (!receiver_chain_admits(callee_name, arr->items[0])) { + if (!receiver_chain_admits(callee_name, arr->items[0], lang)) { return empty_result(); } double conf = CONF_UNIQUE_NAME; @@ -1890,7 +1921,7 @@ static cbm_resolution_t resolve_name_candidates(const cbm_registry_t *r, const q const char *best = best_by_import_distance(r, (const char **)arr->items, qn_test_flags(arr), qn_lang_flags(arr), arr->count, module_qn); if (best) { - if (!receiver_chain_admits(callee_name, best)) { + if (!receiver_chain_admits(callee_name, best, lang)) { return empty_result(); } double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count); @@ -1908,7 +1939,7 @@ static cbm_resolution_t resolve_name_candidates(const cbm_registry_t *r, const q * libc `read()` the unique name of a C++ adapter's Method instead. */ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_vals, - int import_count, bool skip_members) { + int import_count, bool skip_members, CBMLanguage lang) { const char *lookup = simple_name(callee_name); const qn_array_t *arr = cbm_ht_get(r->by_name, lookup); if (!arr || arr->count == 0) { @@ -1926,17 +1957,20 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char if (view.count == 0) { return empty_result(); } - return resolve_name_candidates(r, &view, callee_name, module_qn, import_vals, import_count); + return resolve_name_candidates(r, &view, callee_name, module_qn, import_vals, import_count, + lang); } - return resolve_name_candidates(r, arr, callee_name, module_qn, import_vals, import_count); + return resolve_name_candidates(r, arr, callee_name, module_qn, import_vals, import_count, lang); } /* The strategy chain shared by both public resolve variants (no caching here — - * cbm_registry_resolve owns the per-file cache). */ + * cbm_registry_resolve owns the per-file cache). `lang`: see + * receiver_chain_admits. */ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count, - size_t scope_len, bool skip_members) { + size_t scope_len, bool skip_members, + CBMLanguage lang) { /* Split callee at the first path separator: "pkg.Func" → prefix="pkg", * suffix="Func". Rust/C++ use "::" rather than ".", so honor whichever * separator appears first ("lib::square" → prefix="lib", suffix="square"). @@ -1974,7 +2008,7 @@ static cbm_resolution_t registry_resolve_chain(const cbm_registry_t *r, const ch if (!(res.qualified_name && res.qualified_name[0])) { /* Strategy 3+4: name lookup */ res = resolve_name_lookup(r, callee_name, module_qn, import_map_vals, import_map_count, - skip_members); + skip_members, lang); } return res; } @@ -2022,25 +2056,31 @@ bool cbm_registry_name_guess_vetoed(const cbm_registry_t *r, CBMLanguage caller_ registry_entry_language(r, res->qualified_name)); } +/* call_lang: the calling file's language for a CALLS resolve, CBM_LANG_COUNT + * for every other one (see receiver_chain_admits). */ static cbm_resolution_t registry_resolve_scoped(const cbm_registry_t *r, const char *callee_name, const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count, - size_t scope_len, bool skip_members) { + size_t scope_len, bool skip_members, + CBMLanguage call_lang) { if (!r || !callee_name) { return empty_result(); } /* Per-file cache: same callee_name in N call sites → 1 chain walk * + N-1 O(1) hash hits. module_qn is constant per file so the - * cache key only needs callee_name. */ + * cache key only needs callee_name. The chain reads call_lang only as + * "is it C#", so that one bit is all the entry has to record. */ + bool cs_keywords = call_lang == CBM_LANG_CSHARP; resolve_cache_entry_t *cached = _resolve_cache ? (resolve_cache_entry_t *)cbm_ht_get(_resolve_cache, callee_name) : NULL; - if (cached && cached->scope_len == scope_len && cached->skip_members == skip_members) + if (cached && cached->scope_len == scope_len && cached->skip_members == skip_members && + cached->cs_keywords == cs_keywords) return cached->res; cbm_resolution_t res = registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals, - import_map_count, scope_len, skip_members); + import_map_count, scope_len, skip_members, call_lang); /* Data relations (Table/View) are lineage-only registry members: common * table names (users, orders, config) collide with code identifiers across @@ -2061,12 +2101,14 @@ static cbm_resolution_t registry_resolve_scoped(const cbm_registry_t *r, const c cached->res = res; cached->scope_len = scope_len; cached->skip_members = skip_members; + cached->cs_keywords = cs_keywords; } else if (_resolve_cache) { resolve_cache_entry_t *e = (resolve_cache_entry_t *)malloc(sizeof(*e)); if (e) { e->res = res; e->scope_len = scope_len; e->skip_members = skip_members; + e->cs_keywords = cs_keywords; char *kdup = strdup(callee_name); if (kdup) { cbm_ht_set(_resolve_cache, kdup, e); @@ -2082,7 +2124,8 @@ cbm_resolution_t cbm_registry_resolve(const cbm_registry_t *r, const char *calle const char *module_qn, const char **import_map_keys, const char **import_map_vals, int import_map_count) { return registry_resolve_scoped(r, callee_name, module_qn, import_map_keys, import_map_vals, - import_map_count, module_qn ? strlen(module_qn) : 0, false); + import_map_count, module_qn ? strlen(module_qn) : 0, false, + CBM_LANG_COUNT); } static size_t lang_scope_len(const char *module_qn, CBMLanguage lang) { @@ -2095,7 +2138,8 @@ cbm_resolution_t cbm_registry_resolve_lang(const cbm_registry_t *r, const char * const char **import_map_vals, int import_map_count, CBMLanguage lang) { return registry_resolve_scoped(r, callee_name, module_qn, import_map_keys, import_map_vals, - import_map_count, lang_scope_len(module_qn, lang), false); + import_map_count, lang_scope_len(module_qn, lang), false, + CBM_LANG_COUNT); } cbm_resolution_t cbm_registry_resolve_call_lang(const cbm_registry_t *r, const char *callee_name, @@ -2109,7 +2153,8 @@ cbm_resolution_t cbm_registry_resolve_call_lang(const cbm_registry_t *r, const c bool skip_members = lang == CBM_LANG_C && callee_name && !strchr(callee_name, '.') && !strstr(callee_name, "->"); return registry_resolve_scoped(r, callee_name, module_qn, import_map_keys, import_map_vals, - import_map_count, lang_scope_len(module_qn, lang), skip_members); + import_map_count, lang_scope_len(module_qn, lang), skip_members, + lang); } /* ── Class-qualified route handlers (PHP, #1146) ─────────────────── */ @@ -2277,7 +2322,8 @@ cbm_resolution_t cbm_registry_resolve_lineage(const cbm_registry_t *r, const cha * it would poison one variant with the other's semantics. SQL files hold * few distinct relation refs, so the chain walk stays cheap. */ return registry_resolve_chain(r, callee_name, module_qn, import_map_keys, import_map_vals, - import_map_count, module_qn ? strlen(module_qn) : 0, false); + import_map_count, module_qn ? strlen(module_qn) : 0, false, + CBM_LANG_COUNT); } /* Python looks a bare name up in the calling def's own scope, then in each diff --git a/tests/test_parallel.c b/tests/test_parallel.c index a641e7e0e..b69191a3b 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -2905,6 +2905,100 @@ TEST(parallel_csharp_same_name_class_binds_by_namespace) { PASS(); } +/* #2121, the issue's own two files: C# spells its built-in types as lower-case + * keywords, so `int.TryParse(...)` calls the BCL, never the project's only + * TryParse. The LSP's answer (System.Int32.TryParse) has no graph node, so the + * call falls through to the registry, whose receiver chain check used to skip + * every lower-case root. Both resolvers must refuse the name guess (also for + * the C# 9 `nint`), and a real call to the project type must still bind. */ +TEST(parallel_csharp_keyword_receiver_no_name_guess_issue2121) { + static const char decoys_src[] = + "namespace Contoso.App;\n" + "\n" + "public class TsidNode\n" + "{\n" + " public static bool TryParse(string text, out TsidNode n)\n" + " {\n" + " n = null;\n" + " return false;\n" + " }\n" + "}\n"; + static const char demo_src[] = "using Microsoft.Extensions.Options;\n" + "\n" + "namespace Contoso.App;\n" + "\n" + "public class Demo\n" + "{\n" + " public string M(string input)\n" + " {\n" + " _ = int.TryParse(input, out _);\n" + " _ = nint.TryParse(input, out _);\n" + " return input;\n" + " }\n" + "\n" + " public string Control(string input)\n" + " {\n" + " _ = TsidNode.TryParse(input, out _);\n" + " return input;\n" + " }\n" + "}\n"; + const char *project = "cbm_cs_2121"; + static const char *rels[] = {"src/Decoys.cs", "src/Demo.cs"}; + const char *srcs[] = {decoys_src, demo_src}; + enum { N_FILES = 2 }; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_cs_2121_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + char paths[N_FILES][512]; + cbm_file_info_t files[N_FILES] = {0}; + for (int i = 0; i < N_FILES; i++) { + snprintf(paths[i], sizeof(paths[i]), "%s/%s", tmpdir, rels[i]); + if (th_write_file(paths[i], srcs[i]) != 0) { + th_rmtree(tmpdir); + FAIL("failed to write #2121 fixture"); + } + files[i].path = paths[i]; + files[i].rel_path = (char *)rels[i]; + files[i].language = CBM_LANG_CSHARP; + } + + cbm_gbuf_t *graphs[2] = {run_sequential_with_lsp_cross(project, tmpdir, files, N_FILES), NULL}; + g_harness_cs_registry = true; + graphs[1] = run_parallel(project, tmpdir, files, N_FILES, 2); + g_harness_cs_registry = false; + ASSERT_NOT_NULL(graphs[0]); + ASSERT_NOT_NULL(graphs[1]); + + const char *tsid = "cbm_cs_2121.src.Decoys.TsidNode.TryParse"; + bool ok[2][4]; + bool all = true; + for (int g = 0; g < 2; g++) { + cbm_gbuf_t *gb = graphs[g]; + /* The negative below must not pass because a node went missing. */ + ok[g][0] = cbm_gbuf_find_by_qn(gb, tsid) != NULL; + ok[g][1] = find_unique_callable_node_by_tail(gb, "Demo.M") != NULL; + ok[g][2] = !has_edge_from_callable_to_qn(gb, "Demo.M", tsid, "CALLS"); + ok[g][3] = has_edge_from_callable_to_qn(gb, "Demo.Control", tsid, "CALLS"); + for (int k = 0; k < 4; k++) { + all = all && ok[g][k]; + } + } + if (!all) { + for (int g = 0; g < 2; g++) { + printf(" #2121 diagnostic %s: target=%d caller=%d no_keyword_guess=%d control=%d\n", + g == 0 ? "sequential" : "parallel", ok[g][0], ok[g][1], ok[g][2], ok[g][3]); + } + } + cbm_gbuf_free(graphs[0]); + cbm_gbuf_free(graphs[1]); + th_rmtree(tmpdir); + ASSERT_TRUE(all); + PASS(); +} + /* External Kotlin protocol targets must not borrow a project method merely * because the final Class.method segments agree. Raw assertions prove that the * iterator/destructuring carriers and their stdlib semantic rows really exist; @@ -5573,6 +5667,7 @@ SUITE(parallel) { RUN_TEST(parallel_typescript_import_namespace_exact_parity); RUN_TEST(parallel_tsx_import_namespace_exact_parity); RUN_TEST(parallel_csharp_same_name_class_binds_by_namespace); + RUN_TEST(parallel_csharp_keyword_receiver_no_name_guess_issue2121); RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail); RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph); RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4f2c93554..c933dfecb 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -21468,17 +21468,19 @@ TEST(registry_receiver_chain_ignores_lowercase_root_issue1893) { } /* Issue #2121: unlike vm.load's lower-case value root above, C#'s built-in - * type keywords (int, string, bool, ...) DO name a type and must get the - * same chain-consistency check an upper-case root gets. is_csharp=true here - * because this is exactly the C# file the maintainer's review (PR #2170) - * asked the gate to apply to: every production caller reaches this branch - * only when the file being resolved is CBM_LANG_CSHARP. */ + * type keywords (int, string, bool, nint, ...) DO name a type and must get the + * same chain-consistency check an upper-case root gets. The check belongs to + * the CALLS entry point both resolvers share, for a C# file. */ TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "TryParse", "tmp.Decoys.TsidNode.TryParse", "Method"); - cbm_resolution_t r = - cbm_registry_resolve_lang(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0, true); + cbm_resolution_t r = cbm_registry_resolve_call_lang(reg, "int.TryParse", "tmp.Demo", NULL, NULL, + 0, CBM_LANG_CSHARP); + ASSERT_NULL(r.qualified_name); + /* C# 9 native-sized integers are keywords in receiver position too. */ + r = cbm_registry_resolve_call_lang(reg, "nint.TryParse", "tmp.Demo", NULL, NULL, 0, + CBM_LANG_CSHARP); ASSERT_NULL(r.qualified_name); cbm_registry_free(reg); @@ -21487,48 +21489,46 @@ TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121) { /* The true positive the new keyword check must not eat: the project really * does define a type literally named "int" (or similar) and the chain shows - * it, so the match must still go through. is_csharp=true so this actually - * exercises the keyword branch's ancestry walk, not the is_csharp=false - * early return (which would pass through for the wrong reason). */ + * it, so the match must still go through. Resolved as a C# call, so this + * exercises the keyword branch's ancestry walk, not the non-C# early return + * (which would pass through for the wrong reason). */ TEST(registry_receiver_chain_keeps_type_keyword_when_chain_matches_issue2121) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "TryParse", "tmp.int.TryParse", "Method"); - cbm_resolution_t r = - cbm_registry_resolve_lang(reg, "int.TryParse", "tmp.Demo", NULL, NULL, 0, true); + cbm_resolution_t r = cbm_registry_resolve_call_lang(reg, "int.TryParse", "tmp.Demo", NULL, NULL, + 0, CBM_LANG_CSHARP); ASSERT_STR_EQ(r.qualified_name, "tmp.int.TryParse"); cbm_registry_free(reg); PASS(); } -/* Review ask #1/#2 (PR #2170, DeusData): the type-keyword branch above is a - * C#-only rule (closed list, gated on is_csharp) and must not trade away - * recall in a language where the same spellings are ordinary identifiers. - * `object` and `string` are ordinary lower-case variable names in Python/Go; - * a variable named `object` calling a method (`object.TryParse` shaped - * exactly like the refusal test above) must still resolve as a value root - * when is_csharp is false: the negative control pinning the other side of - * the gate. */ +/* The type-keyword branch above is a C#-only rule (closed list, gated on the + * calling file's language) and must not trade away recall in a language where + * the same spellings are ordinary identifiers. `object` and `string` are + * ordinary lower-case variable names in Python/Go; a variable named `object` + * calling a method (`object.TryParse`, shaped exactly like the refusal test + * above) must still resolve as a value root there: the negative control + * pinning the other side of the gate. */ TEST(registry_receiver_chain_keyword_list_gated_to_csharp_issue2121) { cbm_registry_t *reg = cbm_registry_new(); cbm_registry_add(reg, "TryParse", "tmp.Decoys.TsidNode.TryParse", "Method"); - /* Same callee/candidate shape as - * registry_receiver_chain_refuses_lowercase_type_keyword_issue2121 above, - * but resolved as a Python/Go file (is_csharp=false): "object" must be - * treated as an ordinary value root, not a reserved type keyword. */ - cbm_resolution_t r = - cbm_registry_resolve_lang(reg, "object.TryParse", "tmp.Demo", NULL, NULL, 0, false); + cbm_resolution_t r = cbm_registry_resolve_call_lang(reg, "object.TryParse", "tmp.Demo", NULL, + NULL, 0, CBM_LANG_PYTHON); ASSERT_STR_EQ(r.qualified_name, "tmp.Decoys.TsidNode.TryParse"); ASSERT_STR_EQ(r.strategy, "unique_name"); - /* The language-blind cbm_registry_resolve wrapper IS cbm_registry_resolve_lang - * with is_csharp=false, not a separate code path: confirm the every-day - * entry point behaves identically for a second keyword spelling. */ + /* A Go call and the language-blind cbm_registry_resolve read a second + * keyword spelling as a value root as well. */ cbm_registry_t *reg2 = cbm_registry_new(); cbm_registry_add(reg2, "Join", "tmp.Decoys.Joiner.Join", "Method"); - cbm_resolution_t r2 = cbm_registry_resolve(reg2, "string.Join", "tmp.Demo", NULL, NULL, 0); + cbm_resolution_t r2 = + cbm_registry_resolve_call_lang(reg2, "string.Join", "tmp.Demo", NULL, NULL, 0, CBM_LANG_GO); + ASSERT_STR_EQ(r2.qualified_name, "tmp.Decoys.Joiner.Join"); + ASSERT_STR_EQ(r2.strategy, "unique_name"); + r2 = cbm_registry_resolve(reg2, "string.Join", "tmp.Demo", NULL, NULL, 0); ASSERT_STR_EQ(r2.qualified_name, "tmp.Decoys.Joiner.Join"); ASSERT_STR_EQ(r2.strategy, "unique_name"); From 45bac491ed103993bc29d2e1ecba9ba9ad8e6fa4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 10 Nov 2026 23:06:35 +0100 Subject: [PATCH 07/16] fix(daemon): cache only the process's own image fingerprint The fingerprint cache sat in every fingerprinting acquire, including peer verification. Limit it to the process's own image: every acquire site now passes the process id to the shared resolve step, which marks a peer's key invalid, so a peer is never looked up, never committed, and is hashed on every fingerprint. Re-hashing of verified peer copies is handled by the runtime service's retained peer references (#2335). The daemon's saving is unchanged: it hashes its own image once at start instead of twice. A process has one own image, so the cache drops from 8 entries to 1, which removes the slot search and the round-robin eviction. The cache comment no longer claims that SetFileTime cannot reset the Windows change time; SetFileInformationByHandle can set it. Limited to the own image, the key does not rely on it: Windows keeps a running image locked against modification for as long as the process, and so the cache, exists. Test: daemon_runtime_process_fingerprint_caches_only_own_image spawns this runner as a peer, whose image has the same file identity as the own image, and asserts that two peer fingerprints cost two hashes, two own fingerprints one more, and a later peer fingerprint one more again. Before this change the second peer fingerprint was served from the cache. Signed-off-by: Martin Vogel --- src/daemon/runtime.c | 163 +++++++++++++++++------------------- tests/test_daemon_runtime.c | 62 ++++++++++++++ 2 files changed, 138 insertions(+), 87 deletions(-) diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index de8690494..7a8b7b481 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -754,23 +754,31 @@ static bool runtime_posix_stat_same_image(const struct stat *first, const struct /* ── Executable-image fingerprint cache ────────────────────────────────────── * The build-identity fingerprint is a SHA-256 over the entire executable - * (cbm_daemon_build_fingerprint_native_file): about a second of CPU per hash - * for a ~300 MB image. A daemon process otherwise pays it twice at start (the + * (cbm_daemon_build_fingerprint_native_file), a full read of a ~300 MB image. + * A daemon process otherwise pays it twice for its own image at start (the * supervisor's startup capture, then the runtime service's active-image check), - * so the digest is cached per process, keyed by the file identity the acquire - * path verifies as stable across the hash: device, inode, size, mtime and - * change time (on Windows FILE_BASIC_INFO.ChangeTime, which the kernel - * maintains and SetFileTime cannot reset, unlike the creation time). A rebuilt - * or replaced binary rolls that key and misses, so the cache never returns a - * stale digest and build-cohort admission keeps its exact meaning. The cache is - * process-local: a fresh process (a hook, a CLI call) still hashes its own image - * once. A hit does not weaken the identity proof: the acquire chain still - * brackets the held image with its before/after stat and process-maps checks, - * so the returned digest is bound to that same verified image; only the re-read - * of bytes whose identity is unchanged is skipped. A freshly hashed digest is - * committed only after that post-hash check has passed, under the identity - * observed after the hash, so an image that changed under the hash is never - * cached. */ + * so the digest of this process's OWN image is cached, keyed by the file + * identity the acquire path verifies as stable across the hash: device, inode, + * size, mtime and change time (on Windows FILE_BASIC_INFO.ChangeTime). A peer's + * image is never cached: it is hashed on every fingerprinting acquire + * (re-hashing of a verified peer copy is avoided separately, #1955). + * + * Limiting the cache to the own image is what makes that key sufficient. On + * POSIX the change time moves on every write and cannot be set back. A Windows + * change time can be set through SetFileInformationByHandle, but Windows keeps + * a running image locked against modification for as long as the process, and + * so this cache, exists. A rebuilt or replaced binary rolls the key and misses, + * so the cache never returns a stale digest and build-cohort admission keeps + * its exact meaning. One entry suffices: a process has one own image. + * + * The cache is process-local: a fresh process (a hook, a CLI call) still hashes + * its own image once. A hit does not weaken the identity proof: the acquire + * chain still brackets the held image with its before/after stat and + * process-maps checks, so the returned digest is bound to that same verified + * image; only the re-read of bytes whose identity is unchanged is skipped. A + * freshly hashed digest is committed only after that post-hash check has + * passed, under the identity observed after the hash, so an image that changed + * under the hash is never cached. */ #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || \ defined(__NetBSD__) @@ -787,15 +795,10 @@ typedef struct { int64_t ctime_nanoseconds; } runtime_fingerprint_key_t; -#define RUNTIME_FINGERPRINT_CACHE_CAP 8 - +/* The single entry: this process's own image. */ static atomic_flag runtime_fingerprint_cache_lock = ATOMIC_FLAG_INIT; -static struct { - runtime_fingerprint_key_t key; - char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; -} runtime_fingerprint_cache[RUNTIME_FINGERPRINT_CACHE_CAP]; -static size_t runtime_fingerprint_cache_count; -static size_t runtime_fingerprint_cache_victim; +static runtime_fingerprint_key_t runtime_fingerprint_cache_key; +static char runtime_fingerprint_cache_digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; #ifdef CBM_ENABLE_TEST_SEAMS static atomic_int runtime_fingerprint_hash_call_count; @@ -825,15 +828,10 @@ static bool runtime_fingerprint_cache_lookup(const runtime_fingerprint_key_t *ke if (!key->valid) { return false; } - bool hit = false; runtime_fingerprint_cache_enter(); - for (size_t index = 0; index < runtime_fingerprint_cache_count; index++) { - if (runtime_fingerprint_key_equal(&runtime_fingerprint_cache[index].key, key)) { - memcpy(out, runtime_fingerprint_cache[index].fingerprint, - CBM_DAEMON_BUILD_FINGERPRINT_SIZE); - hit = true; - break; - } + bool hit = runtime_fingerprint_key_equal(&runtime_fingerprint_cache_key, key); + if (hit) { + memcpy(out, runtime_fingerprint_cache_digest, CBM_DAEMON_BUILD_FINGERPRINT_SIZE); } runtime_fingerprint_cache_leave(); return hit; @@ -846,25 +844,8 @@ static void runtime_fingerprint_cache_store( return; } runtime_fingerprint_cache_enter(); - size_t slot = RUNTIME_FINGERPRINT_CACHE_CAP; - for (size_t index = 0; index < runtime_fingerprint_cache_count; index++) { - if (runtime_fingerprint_key_equal(&runtime_fingerprint_cache[index].key, key)) { - slot = index; - break; - } - } - if (slot == RUNTIME_FINGERPRINT_CACHE_CAP) { - if (runtime_fingerprint_cache_count < RUNTIME_FINGERPRINT_CACHE_CAP) { - slot = runtime_fingerprint_cache_count++; - } else { - slot = runtime_fingerprint_cache_victim; - runtime_fingerprint_cache_victim = - (runtime_fingerprint_cache_victim + 1) % RUNTIME_FINGERPRINT_CACHE_CAP; - } - } - runtime_fingerprint_cache[slot].key = *key; - memcpy(runtime_fingerprint_cache[slot].fingerprint, fingerprint, - CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + runtime_fingerprint_cache_key = *key; + memcpy(runtime_fingerprint_cache_digest, fingerprint, CBM_DAEMON_BUILD_FINGERPRINT_SIZE); runtime_fingerprint_cache_leave(); } @@ -883,16 +864,22 @@ static bool runtime_fingerprint_hash_native_file(uintptr_t native_file, return cbm_daemon_build_fingerprint_native_file(native_file, out); } -/* Resolve the digest for the image identity `key`: from the cache when it - * already holds that identity, otherwise by hashing the held native image. - * `cached` reports which. A freshly hashed digest is deliberately not stored - * here: the acquire site commits it (runtime_fingerprint_cache_commit) only - * once its post-hash identity check has proven the hashed bytes belong to the - * identity they were keyed on. */ -static bool runtime_build_fingerprint_resolve(uintptr_t native_file, - const runtime_fingerprint_key_t *key, +/* Resolve the digest for the image identity `key` of process `process_id`: + * from the cache when that process is this one and the cache already holds + * that identity, otherwise by hashing the held native image. `cached` reports + * which. Every acquire site resolves through here, so this is where the cache + * is limited to the own image: a peer's key is marked invalid, which is never + * looked up and never committed. A freshly hashed digest is deliberately not + * stored here: the acquire site commits it (runtime_fingerprint_cache_commit) + * only once its post-hash identity check has proven the hashed bytes belong to + * the identity they were keyed on. */ +static bool runtime_build_fingerprint_resolve(uintptr_t native_file, uint64_t process_id, + runtime_fingerprint_key_t *key, char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], bool *cached) { + if (process_id != runtime_current_process_id()) { + key->valid = false; + } *cached = runtime_fingerprint_cache_lookup(key, fingerprint); return *cached || runtime_fingerprint_hash_native_file(native_file, fingerprint); } @@ -900,7 +887,8 @@ static bool runtime_build_fingerprint_resolve(uintptr_t native_file, /* Commit a freshly hashed digest after the acquire chain has passed. The * identity observed after the hash must equal the one the digest was resolved * under; otherwise the bytes read belong to no single identity and are not - * cached. A cache hit has nothing to commit. */ + * cached. A cache hit has nothing to commit, and neither has a peer, whose + * resolved key is invalid. */ static void runtime_fingerprint_cache_commit( const runtime_fingerprint_key_t *before, const runtime_fingerprint_key_t *after, bool cached, const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { @@ -938,11 +926,11 @@ static void runtime_fingerprint_key_windows(HANDLE file, } static bool runtime_build_fingerprint_resolve_windows( - HANDLE file, const BY_HANDLE_FILE_INFORMATION *information, const LARGE_INTEGER *size, - char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], runtime_fingerprint_key_t *key, - bool *cached) { + HANDLE file, uint64_t process_id, const BY_HANDLE_FILE_INFORMATION *information, + const LARGE_INTEGER *size, char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], + runtime_fingerprint_key_t *key, bool *cached) { runtime_fingerprint_key_windows(file, information, size, key); - return runtime_build_fingerprint_resolve((uintptr_t)file, key, fingerprint, cached); + return runtime_build_fingerprint_resolve((uintptr_t)file, process_id, key, fingerprint, cached); } static void runtime_fingerprint_cache_commit_windows( @@ -977,11 +965,11 @@ static void runtime_fingerprint_key_posix(const struct stat *status, } static bool runtime_build_fingerprint_resolve_posix( - uintptr_t native_file, const struct stat *status, + uintptr_t native_file, uint64_t process_id, const struct stat *status, char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], runtime_fingerprint_key_t *key, bool *cached) { runtime_fingerprint_key_posix(status, key); - return runtime_build_fingerprint_resolve(native_file, key, fingerprint, cached); + return runtime_build_fingerprint_resolve(native_file, process_id, key, fingerprint, cached); } static void runtime_fingerprint_cache_commit_posix( @@ -996,9 +984,8 @@ static void runtime_fingerprint_cache_commit_posix( #ifdef CBM_ENABLE_TEST_SEAMS void cbm_daemon_runtime_fingerprint_cache_reset_for_testing(void) { runtime_fingerprint_cache_enter(); - memset(runtime_fingerprint_cache, 0, sizeof(runtime_fingerprint_cache)); - runtime_fingerprint_cache_count = 0; - runtime_fingerprint_cache_victim = 0; + memset(&runtime_fingerprint_cache_key, 0, sizeof(runtime_fingerprint_cache_key)); + memset(runtime_fingerprint_cache_digest, 0, sizeof(runtime_fingerprint_cache_digest)); runtime_fingerprint_cache_leave(); atomic_store_explicit(&runtime_fingerprint_hash_call_count, 0, memory_order_relaxed); atomic_store_explicit(&runtime_fingerprint_hash_stub_active, false, memory_order_release); @@ -1041,7 +1028,8 @@ bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( const cbm_daemon_runtime_fingerprint_identity_for_testing_t *after, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { /* There is no native image behind a synthetic identity: only the stubbed - * hash may ever be reached from here. */ + * hash may ever be reached from here. The identity stands for this + * process's own image, the only one the cache serves. */ if (!before || !after || !out || !atomic_load_explicit(&runtime_fingerprint_hash_stub_active, memory_order_acquire)) { return false; @@ -1049,7 +1037,8 @@ bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( runtime_fingerprint_key_t key_before = runtime_fingerprint_key_for_testing(before); runtime_fingerprint_key_t key_after = runtime_fingerprint_key_for_testing(after); bool cached = false; - if (!runtime_build_fingerprint_resolve((uintptr_t)0, &key_before, out, &cached)) { + if (!runtime_build_fingerprint_resolve((uintptr_t)0, runtime_current_process_id(), &key_before, + out, &cached)) { return false; } runtime_fingerprint_cache_commit(&key_before, &key_after, cached, out); @@ -1102,9 +1091,9 @@ static bool runtime_process_image_reference_acquire( : INVALID_HANDLE_VALUE; free(open_path); ok = ok && runtime_windows_file_snapshot(file, &file_before, &size_before) && - (!fingerprint || - runtime_build_fingerprint_resolve_windows(file, &file_before, &size_before, fingerprint, - &fingerprint_key, &fingerprint_cached)) && + (!fingerprint || runtime_build_fingerprint_resolve_windows( + file, process_id, &file_before, &size_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && runtime_windows_file_snapshot(file, &file_after, &size_after) && runtime_windows_file_snapshot_same(&file_before, &size_before, &file_after, &size_after) && runtime_windows_process_image_snapshot(process, &process_after) && @@ -1146,9 +1135,9 @@ static bool runtime_process_image_reference_acquire( bool fingerprint_cached = false; ok = ok && fd >= 0 && fstat(fd, &file_before) == 0 && S_ISREG(file_before.st_mode) && runtime_mac_process_maps_file_executable(pid, &file_before) && - (!fingerprint || - runtime_build_fingerprint_resolve_posix((uintptr_t)fd, &file_before, fingerprint, - &fingerprint_key, &fingerprint_cached)) && + (!fingerprint || runtime_build_fingerprint_resolve_posix( + (uintptr_t)fd, process_id, &file_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && fstat(fd, &file_after) == 0 && runtime_mac_stat_same(&file_before, &file_after) && runtime_mac_process_maps_file_executable(pid, &file_after) && runtime_mac_process_instance(pid, &process_after) && @@ -1177,13 +1166,13 @@ static bool runtime_process_image_reference_acquire( struct stat image_after; runtime_fingerprint_key_t fingerprint_key = {0}; bool fingerprint_cached = false; - bool ok = - image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && - (!fingerprint || - runtime_build_fingerprint_resolve_posix((uintptr_t)image_fd, &image_before, fingerprint, - &fingerprint_key, &fingerprint_cached)) && - fstat(image_fd, &image_after) == 0 && - runtime_posix_stat_same_image(&image_before, &image_after); + bool ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && + S_ISREG(image_before.st_mode) && + (!fingerprint || runtime_build_fingerprint_resolve_posix( + (uintptr_t)image_fd, process_id, &image_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && + fstat(image_fd, &image_after) == 0 && + runtime_posix_stat_same_image(&image_before, &image_after); int verify_fd = ok ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; struct stat verify_status; ok = ok && verify_fd >= 0 && fstat(verify_fd, &verify_status) == 0 && @@ -1227,9 +1216,9 @@ static bool runtime_process_image_reference_acquire( runtime_fingerprint_key_t fingerprint_key = {0}; bool fingerprint_cached = false; ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && - (!fingerprint || - runtime_build_fingerprint_resolve_posix((uintptr_t)image_fd, &image_before, fingerprint, - &fingerprint_key, &fingerprint_cached)) && + (!fingerprint || runtime_build_fingerprint_resolve_posix( + (uintptr_t)image_fd, process_id, &image_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && fstat(image_fd, &image_after) == 0 && runtime_posix_stat_same_image(&image_before, &image_after); if (ok) { diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 49ca1f894..8cfa4b04c 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -5051,6 +5051,65 @@ TEST(daemon_runtime_process_fingerprint_hashes_own_image_once_per_process) { PASS(); } +#if defined(__APPLE__) || defined(__linux__) +/* Only this process's own image is cached. The peer here is this very runner + * spawned in holder mode, so its image has exactly the file identity the own + * image is cached under: a cache keyed on that identity alone would answer for + * the peer. A peer must be hashed on every fingerprint, before and after the + * own image is cached, while the own image still costs one hash. */ +TEST(daemon_runtime_process_fingerprint_caches_only_own_image) { + static const char digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + char image_path[RUNTIME_TEST_PATH_CAP] = {0}; + char peer_first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char peer_second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char own_first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char own_second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char peer_after_own[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + int release_fd = -1; + bool setup = runtime_test_self_image_path(image_path); + pid_t child = setup ? runtime_test_spawn_blocked_executable(image_path, &release_fd) : -1; + setup = setup && child > 0; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest); + bool peer_first_ok = + setup && cbm_daemon_runtime_process_build_fingerprint((uint64_t)child, peer_first); + bool peer_second_ok = + setup && cbm_daemon_runtime_process_build_fingerprint((uint64_t)child, peer_second); + int hashes_after_peer = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + bool own_first_ok = + cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), own_first); + bool own_second_ok = + cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), own_second); + int hashes_after_own = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + bool peer_after_own_ok = + setup && cbm_daemon_runtime_process_build_fingerprint((uint64_t)child, peer_after_own); + int hashes_after_peer_again = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + runtime_test_stop_blocked_executable(child, release_fd); + + ASSERT_TRUE(setup); + ASSERT_TRUE(peer_first_ok); + ASSERT_TRUE(peer_second_ok); + ASSERT_TRUE(own_first_ok); + ASSERT_TRUE(own_second_ok); + ASSERT_TRUE(peer_after_own_ok); + ASSERT_STR_EQ(peer_first, digest); + ASSERT_STR_EQ(peer_second, digest); + ASSERT_STR_EQ(own_first, digest); + ASSERT_STR_EQ(own_second, digest); + ASSERT_STR_EQ(peer_after_own, digest); + /* Each peer fingerprint pays its own hash: a peer is never cached. */ + ASSERT_EQ(hashes_after_peer, 2); + /* The own image is hashed once, then served from the cache. */ + ASSERT_EQ(hashes_after_own, 3); + /* The cached own entry never answers for the peer's identical file. */ + ASSERT_EQ(hashes_after_peer_again, 4); + PASS(); +} +#endif + TEST(daemon_runtime_close_begin_releases_admission_with_inflight_request) { static const uint8_t request[] = {'b', 'l', 'o', 'c', 'k'}; cbm_daemon_build_identity_t identity = @@ -5248,6 +5307,9 @@ SUITE(daemon_runtime) { RUN_TEST(daemon_runtime_fingerprint_cache_hit_miss_key_roll); RUN_TEST(daemon_runtime_fingerprint_cache_skips_image_changed_under_hash); RUN_TEST(daemon_runtime_process_fingerprint_hashes_own_image_once_per_process); +#if defined(__APPLE__) || defined(__linux__) + RUN_TEST(daemon_runtime_process_fingerprint_caches_only_own_image); +#endif RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); From 79396457555a1f3be3befcdb602e03adad0f187c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 00:11:21 +0100 Subject: [PATCH 08/16] fix(registry): keep C# dynamic receivers as value roots `dynamic` is a contextual keyword, not a reserved type keyword like `int`. The dynamic type has no static members, so a `dynamic.X` receiver is a variable named `dynamic` (or a user type spelled `@dynamic`), never the built-in type. On the keyword list it sent such a call through the receiver chain check and refused it. Drop `dynamic` from cs_type_keyword_root. `nint` and `nuint` stay: they name the built-in type in receiver position. On dotnet/runtime all 586 `nint.`/`nuint.` member accesses are static members of the built-in types, while none of the 10 `dynamic.` receivers is the built-in type. registry_receiver_chain_csharp_dynamic_variable_still_binds_issue2121 pins it: a C# call through a variable named `dynamic` still binds. It fails with `dynamic` on the list and passes without it. Signed-off-by: Martin Vogel --- src/pipeline/registry.c | 9 +++++---- tests/test_pipeline.c | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 15f9e6b8f..c45ffbc85 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1752,12 +1752,13 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal /* True when the first segment of the dotted chain `dotted` is one of C#'s * built-in type keywords (int, string, bool, ...; nint and nuint since C# 9). - * Closed list: these words are grammar, not a naming heuristic. */ + * Closed list: these words are grammar, not a naming heuristic. `dynamic` is + * not on it: the type has no static members, so `dynamic.X` is a variable + * named `dynamic` and stays an ordinary lower-case value root. */ static bool cs_type_keyword_root(const char *dotted) { static const char *const keywords[] = { - "bool", "byte", "char", "decimal", "double", "dynamic", "float", - "int", "long", "nint", "nuint", "object", "sbyte", "short", - "string", "uint", "ulong", "ushort", "void", + "bool", "byte", "char", "decimal", "double", "float", "int", "long", "nint", + "nuint", "object", "sbyte", "short", "string", "uint", "ulong", "ushort", "void", }; const char *dot = strchr(dotted, '.'); size_t len = dot ? (size_t)(dot - dotted) : strlen(dotted); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 7815c599e..bbf83e624 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -21694,6 +21694,25 @@ TEST(registry_receiver_chain_keyword_list_gated_to_csharp_issue2121) { PASS(); } +/* `dynamic` is a contextual keyword in C#, not a reserved type keyword like + * `int`: in receiver position (`dynamic.Invoke(...)`) it is a variable that + * happens to be called `dynamic`, whose type the chain does not show. In a C# + * file it must stay an ordinary lower-case value root, like vm.load above, so + * the call still binds: the control that keeps the keyword list closed to + * words that can only name a type. */ +TEST(registry_receiver_chain_csharp_dynamic_variable_still_binds_issue2121) { + cbm_registry_t *reg = cbm_registry_new(); + cbm_registry_add(reg, "Invoke", "tmp.Handlers.Handler.Invoke", "Method"); + + cbm_resolution_t r = cbm_registry_resolve_call_lang(reg, "dynamic.Invoke", "tmp.Demo", NULL, + NULL, 0, CBM_LANG_CSHARP); + ASSERT_STR_EQ(r.qualified_name, "tmp.Handlers.Handler.Invoke"); + ASSERT_STR_EQ(r.strategy, "unique_name"); + + cbm_registry_free(reg); + PASS(); +} + /* An unqualified callee has no chain at all and must pass through unchanged. */ TEST(registry_receiver_chain_ignores_bare_name_issue1893) { cbm_registry_t *reg = cbm_registry_new(); @@ -27975,6 +27994,7 @@ SUITE(pipeline) { RUN_TEST(registry_receiver_chain_refuses_lowercase_type_keyword_issue2121); RUN_TEST(registry_receiver_chain_keeps_type_keyword_when_chain_matches_issue2121); RUN_TEST(registry_receiver_chain_keyword_list_gated_to_csharp_issue2121); + RUN_TEST(registry_receiver_chain_csharp_dynamic_variable_still_binds_issue2121); RUN_TEST(registry_receiver_chain_ignores_bare_name_issue1893); RUN_TEST(registry_fuzzy_confidence_single); RUN_TEST(registry_fuzzy_confidence_distance); From fb23905e639b8a7aabca05416f3ecbc0a233a7be Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 00:43:14 +0100 Subject: [PATCH 09/16] test(daemon): pin the idle maintenance-probe cadence The frontend maintenance monitor probes for a maintenance request on every cycle, and each probe try-acquires the maintenance marker lock (on Windows that revalidates the whole ancestor directory chain). The quiet no-maintenance cadence was raised from 10 ms to 250 ms because the 10 ms loop burned a large share of a core for the life of every MCP session, but nothing pinned that cadence: a regression back to the hot loop would pass every suite. The monitor now sleeps on a local idle_ms in its ABSENT branch and, under CBM_ENABLE_TEST_SEAMS only, records that exact value immediately before sleeping on it (cleared by cbm_daemon_frontend_test_observer_reset). The new test starts an idle monitor, waits until a value is recorded, stops the monitor and asserts the recorded sleep is at least 250 ms. Once set, the recorded value is stable, so the verdict depends on neither elapsed time nor scheduler cadence; the deadline is only a hang guard. Production builds carry no seam code and sleep the same 250 ms as before. The original test counted presence observations inside a 100 ms window, which asserts a transient timing window; it is replaced by the recorded cadence above. Refs #2093 Distilled from #2215. Co-authored-by: lorenzozanee Signed-off-by: Martin Vogel --- src/daemon/frontend.c | 12 +++++++++++- src/daemon/frontend.h | 4 ++++ tests/test_daemon_frontend.c | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index e76c920b6..fa81cc06d 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -121,9 +121,11 @@ static atomic_uint_fast64_t frontend_test_worker_observation_count = ATOMIC_VAR_ static atomic_uint_fast64_t frontend_test_worker_idle_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t frontend_test_routed_cancellation_count = ATOMIC_VAR_INIT(0); static atomic_uint_fast32_t frontend_test_replay_budget_ms = ATOMIC_VAR_INIT(0); +static atomic_uint_fast32_t frontend_test_monitor_idle_sleep = ATOMIC_VAR_INIT(0); void cbm_daemon_frontend_test_observer_reset(bool hold_monitor) { atomic_store_explicit(&frontend_test_monitor_observation_count, 0, memory_order_release); + atomic_store_explicit(&frontend_test_monitor_idle_sleep, 0, memory_order_release); atomic_store_explicit(&frontend_test_worker_observation_count, 0, memory_order_release); atomic_store_explicit(&frontend_test_worker_idle_count, 0, memory_order_release); atomic_store_explicit(&frontend_test_monitor_is_waiting, false, memory_order_release); @@ -157,6 +159,10 @@ uint64_t cbm_daemon_frontend_test_routed_cancellations(void) { uint32_t cbm_daemon_frontend_test_replay_timeout_ms(void) { return (uint32_t)atomic_load_explicit(&frontend_test_replay_budget_ms, memory_order_acquire); } + +uint32_t cbm_daemon_frontend_test_monitor_idle_sleep_ms(void) { + return (uint32_t)atomic_load_explicit(&frontend_test_monitor_idle_sleep, memory_order_acquire); +} #endif static cbm_version_cohort_maintenance_presence_t frontend_observe_maintenance( @@ -187,7 +193,11 @@ static void *frontend_maintenance_monitor_worker(void *opaque) { cbm_version_cohort_maintenance_presence_t presence = frontend_observe_maintenance(monitor->manager, true); if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) { - cbm_usleep(FRONTEND_MAINTENANCE_IDLE_POLL_MS * 1000U); + uint32_t idle_ms = FRONTEND_MAINTENANCE_IDLE_POLL_MS; +#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS + atomic_store_explicit(&frontend_test_monitor_idle_sleep, idle_ms, memory_order_release); +#endif + cbm_usleep(idle_ms * 1000U); continue; } diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h index 8ee33d276..4629644bb 100644 --- a/src/daemon/frontend.h +++ b/src/daemon/frontend.h @@ -83,6 +83,10 @@ uint64_t cbm_daemon_frontend_test_routed_cancellations(void); * settings requests. Recovery tests use distinct connect and startup budgets * and compare. */ uint32_t cbm_daemon_frontend_test_replay_timeout_ms(void); +/* Milliseconds the maintenance monitor last slept in its no-maintenance + * (ABSENT) branch, recorded from the exact value passed to the sleep; zero + * until the first idle cycle after reset. Pins the idle probe cadence. */ +uint32_t cbm_daemon_frontend_test_monitor_idle_sleep_ms(void); #endif /* Takes ownership of client and borrows cohort_manager for the complete call. diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index aa49bf4c7..7e5c540ba 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -1641,6 +1641,32 @@ TEST(daemon_frontend_idle_uses_one_maintenance_observer) { ASSERT_TRUE(fixture_closed); PASS(); } + +/* An idle monitor must not repeatedly perform the expensive presence probe + * while no maintenance request exists (#2093). The monitor records the exact + * value it sleeps on in its no-maintenance branch; once recorded that value is + * stable, so the verdict depends on neither elapsed time nor scheduler cadence. + * The deadline below is only a hang guard. */ +TEST(daemon_frontend_idle_maintenance_probe_is_bounded) { + frontend_maintenance_fixture_t fixture; + ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "idle-cadence")); + + cbm_daemon_frontend_test_observer_reset(false); + cbm_daemon_maintenance_monitor_t *monitor = cbm_daemon_maintenance_monitor_start( + fixture.manager, NULL, NULL, EXIT_SUCCESS, "idle cadence test"); + ASSERT_TRUE(monitor != NULL); + uint64_t deadline = cbm_now_ms() + 10000U; + while (cbm_now_ms() < deadline && cbm_daemon_frontend_test_monitor_idle_sleep_ms() == 0) { + cbm_usleep(1000); + } + uint32_t idle_sleep_ms = cbm_daemon_frontend_test_monitor_idle_sleep_ms(); + ASSERT_TRUE(cbm_daemon_maintenance_monitor_stop(&monitor)); + frontend_maintenance_fixture_finish(&fixture); + + ASSERT_TRUE(idle_sleep_ms != 0); + ASSERT_GTE(idle_sleep_ms, 250); + PASS(); +} #endif #ifndef _WIN32 @@ -1982,6 +2008,7 @@ SUITE(daemon_frontend) { RUN_TEST(daemon_frontend_rejects_non_notification_cancellation_shapes); #if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS RUN_TEST(daemon_frontend_idle_uses_one_maintenance_observer); + RUN_TEST(daemon_frontend_idle_maintenance_probe_is_bounded); #endif #ifndef _WIN32 RUN_TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked); From fcf274aa2a480f622361cf7a850c25a01653b6ba Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 00:49:21 +0100 Subject: [PATCH 10/16] test(fqn): guard against Unicode project-name collisions Two repositories whose paths differ only in a non-ASCII segment (for example /Users/dev/ and /Users/dev/) must derive distinct project names. Before the #571 fix every non-ASCII byte was mapped to '-', so both collapsed to the same name and shared one index. The existing #571 test pins one exact string; this test pins the property that two different segments stay distinct and validator-safe. Hardening on top of the original test: each name must also contain the hex image of its own segment (e5bc80e58f91 / e5908ee7abaf), so a future mapping that keeps the names distinct without preserving the segment is caught as well. Refs #571 Distilled from #2219. Co-authored-by: lorenzozanee Signed-off-by: Martin Vogel --- tests/test_fqn.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_fqn.c b/tests/test_fqn.c index 013bea6a9..d2734df0d 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -630,6 +630,23 @@ TEST(project_name_encodes_unicode_segments_issue571) { PASS(); } +TEST(project_name_unicode_segments_do_not_collide_issue571) { + char *first = cbm_project_name_from_path("/Users/dev/\xe5\xbc\x80\xe5\x8f\x91"); + char *second = cbm_project_name_from_path("/Users/dev/\xe5\x90\x8e\xe7\xab\xaf"); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + ASSERT_STR_NEQ(first, second); + /* Distinct is not enough: each name must carry the hex image of its own + * non-ASCII segment, so the segment is preserved rather than replaced. */ + ASSERT_NOT_NULL(strstr(first, "e5bc80e58f91")); + ASSERT_NOT_NULL(strstr(second, "e5908ee7abaf")); + ASSERT_TRUE(cbm_validate_project_name(first)); + ASSERT_TRUE(cbm_validate_project_name(second)); + free(first); + free(second); + PASS(); +} + /* issue #624: #571 preserves non-ASCII path segments by hex-encoding each byte * (1 byte -> 2 hex chars), so a DEEP non-ASCII path triples in length and can * blow past the filesystem's 255-byte filename-component limit. Then @@ -802,6 +819,7 @@ SUITE(fqn) { RUN_TEST(project_name_deep_path); RUN_TEST(project_name_always_validator_safe_issue349); RUN_TEST(project_name_encodes_unicode_segments_issue571); + RUN_TEST(project_name_unicode_segments_do_not_collide_issue571); RUN_TEST(project_name_length_capped_issue624); RUN_TEST(project_name_colon_only); RUN_TEST(project_name_backslash_only); From c05de88247f1e1ea31064bfdca8175403bd072d0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 00:57:46 +0100 Subject: [PATCH 11/16] test(windows): guard clean MCP stdio startup Adds a native Windows guard for #654: a successful MCP initialize + tools/list exchange, run against an isolated cache and runtime directory, must leave nothing on stderr but the server's own structured log lines. - tests/windows/test_mcp_stdio.py: the guard. The English and Spanish path-not-found messages reported in #654 are named cases; any other non-empty stderr line that is not a structured "level= msg=" record fails as well, so the same leak on a machine in another language is caught without listing its translation. The log format is pinned to text so an inherited CBM_LOG_FORMAT=json cannot change the verdict. Cache and runtime live under one temp root, so both are removed with it, also on failure, after the daemon is stopped. - tests/windows/mcp_stdio.py: close() joins the stderr reader, and the new stderr_complete() reports whether the reader reached EOF. The guard reads stderr only after it has; a reader still running after the server exited is a SETUP failure (exit 2), never a verdict on a partial capture. - scripts/test-windows.ps1: registers the guard in the green guard list and documents it in the header. #2213 was an earlier duplicate of the same check. Refs #654. Distilled from #2217. Co-authored-by: lorenzozanee Co-authored-by: Shinken009 <182390694+Shinken009@users.noreply.github.com> Signed-off-by: Martin Vogel --- scripts/test-windows.ps1 | 2 + tests/windows/mcp_stdio.py | 18 +++++ tests/windows/test_mcp_stdio.py | 127 ++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/windows/test_mcp_stdio.py diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index 8c1a8358a..28739163d 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -18,6 +18,7 @@ * test_hook_augment.py guards #618 (fixed by #619) * test_ui_drive_listing.py guards #548 (roots field) * test_cli_non_ascii_arg.py guards #423/#20 (wide-argv main()) + * test_mcp_stdio.py guards #654 (clean stdio stderr) * test_daemon_stability.py guards the daemon parameter surface, crash recovery, busy-stop refusal, and churn * test_windows_update_handoff.py guards that `update` @@ -166,6 +167,7 @@ $guards = @( "tests\windows\test_hook_augment.py", "tests\windows\test_ui_drive_listing.py", "tests\windows\test_cli_non_ascii_arg.py", + "tests\windows\test_mcp_stdio.py", "tests\windows\test_windows_update_handoff.py" ) diff --git a/tests/windows/mcp_stdio.py b/tests/windows/mcp_stdio.py index 218e9a27f..d582d4c10 100644 --- a/tests/windows/mcp_stdio.py +++ b/tests/windows/mcp_stdio.py @@ -25,6 +25,8 @@ class McpServer: self._id = 0 self.proc = None self._stderr = [] + self._stderr_thread = None + self._stderr_eof = False env = dict(os.environ) if cache_dir: env["CBM_CACHE_DIR"] = cache_dir # isolate the graph DB location @@ -52,12 +54,27 @@ class McpServer: try: for line in self.proc.stderr: self._stderr.append(line.decode("utf-8", "replace")) + self._stderr_eof = True except Exception: pass def stderr_text(self): return "".join(self._stderr) + def stderr_complete(self, timeout): + """Wait for the stderr reader to reach EOF; True once it has. + + Only then is stderr_text() the whole capture. False means the reader + is still running (some process still holds the pipe) or stopped on a + read error, so the text may be partial: a caller that asserts on + stderr must treat False as a setup failure, never as a verdict. The + timeout is a liveness backstop, not part of any verdict. + """ + if self._stderr_thread is None: + return False + self._stderr_thread.join(timeout) + return not self._stderr_thread.is_alive() and self._stderr_eof + def _send(self, obj): data = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.proc.stdin.write(data + b"\n") @@ -148,6 +165,7 @@ class McpServer: self.proc.kill() except Exception: pass + self._stderr_thread.join(timeout=10) def wait_projects_with_stats(server, timeout=90.0, poll=1.0): diff --git a/tests/windows/test_mcp_stdio.py b/tests/windows/test_mcp_stdio.py new file mode 100644 index 000000000..7b7eee617 --- /dev/null +++ b/tests/windows/test_mcp_stdio.py @@ -0,0 +1,127 @@ +"""Regression guard for clean Windows MCP stdio startup (#654). + +A successful initialize + tools/list exchange must leave nothing on stderr but +the server's own structured log lines. #654 reported the OS "path not found" +message leaking to stderr on startup, in English and on a Spanish Windows. Those +two messages are named cases; any other non-empty line that is not a structured +`level= msg=` record fails as well, so the same leak on a machine +in any other language is caught without listing its translation. + +The verdict is read only after the stderr reader has reached EOF. A reader that +is still running after the server exited means the capture may be partial, and +that is a setup failure, never a pass. + +Exit codes follow the guard contract of scripts/test-windows.ps1: +0 = GREEN, 1 = RED (regression), 2 = SETUP / precondition. +""" + +import os +import re +import subprocess +import sys +import tempfile + +from mcp_stdio import McpServer + +# The OS path-not-found message as reported in #654 (English and Spanish +# Windows). Named so a RED says exactly which known leak came back. +KNOWN_PATH_ERRORS = ( + "The system cannot find the path specified.", + "El sistema no puede encontrar la ruta especificada.", +) + +# The text-format record cbm_log writes (src/foundation/log.c): +# "level= msg= key=value ...". +STRUCTURED_LOG_LINE = re.compile(r"level=[a-z]+ msg=\S") + +# Liveness backstop for the stderr reader. The server has already exited when +# this is waited on, so the reader is at EOF unless another process still holds +# the pipe. Expiry is a SETUP failure; it never decides PASS or FAIL. +STDERR_EOF_BACKSTOP_S = 60 + + +def classify_stderr(text): + """Return (known, raw): the named #654 messages found, and every other + non-empty stderr line that is not a structured log record.""" + known = [message for message in KNOWN_PATH_ERRORS if message in text] + raw = [] + for line in text.splitlines(): + line = line.strip() + if not line or STRUCTURED_LOG_LINE.match(line): + continue + if any(message in line for message in KNOWN_PATH_ERRORS): + continue + raw.append(line) + return known, raw + + +def main(): + if os.name != "nt": + print("SKIP: Windows-only MCP stdio guard") + return 2 + + binary = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("CBM_TEST_BINARY") + if not binary: + print("SETUP FAIL: CBM_TEST_BINARY is required", file=sys.stderr) + return 2 + + with tempfile.TemporaryDirectory(prefix="cbm-mcp-stdio-") as work: + cache = os.path.join(work, "cache") + runtime = os.path.join(work, "runtime") + os.makedirs(cache) + os.makedirs(runtime) + # The verdict reads the text log shape, so select it explicitly: an + # inherited CBM_LOG_FORMAT=json must not turn every record into a raw + # line. + server = McpServer( + binary, + cache_dir=cache, + extra_env={"CBM_RUNTIME_DIR": runtime, "CBM_LOG_FORMAT": "text"}, + ) + try: + with server: + server.initialize(timeout=30) + server.tools_list(timeout=30) + finally: + stop = subprocess.run( + [binary, "daemon", "stop"], + env=server.env, + capture_output=True, + timeout=30, + ) + if stop.returncode != 0: + detail = (stop.stdout + stop.stderr).decode("utf-8", "replace") + raise RuntimeError( + "daemon cleanup failed (%d): %s" % (stop.returncode, detail.strip()) + ) + + if not server.stderr_complete(STDERR_EOF_BACKSTOP_S): + print( + "SETUP FAIL: the stderr reader did not reach EOF after the server " + "exited; refusing to judge a possibly partial capture", + file=sys.stderr, + ) + return 2 + + known, raw = classify_stderr(server.stderr_text()) + if known or raw: + if known: + print( + "FAIL: startup leaked an OS path error to stderr: " + ", ".join(known), + file=sys.stderr, + ) + if raw: + print( + "FAIL: startup wrote %d stderr line(s) that are not structured " + "level= log records:" % len(raw), + file=sys.stderr, + ) + for line in raw: + print(" " + line, file=sys.stderr) + return 1 + print("PASS: successful MCP startup emitted only structured log lines on stderr") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8a07514de070be3a268200b59971c72fe2060afb Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 20:17:58 +0100 Subject: [PATCH 12/16] style(registry): clang-format the declarations the main merge re-signed index_under_name, swift_choose_candidate and the fuzzy path's filter_import_reachable call gained parameters in the merge of main; this re-wraps exactly those lines the way clang-format (LLVM 23) lays them out. No code change. Signed-off-by: Martin Vogel --- src/pipeline/registry.c | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index fce3c43cc..2b93bc93c 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1666,8 +1666,8 @@ void cbm_registry_free(cbm_registry_t *r) { * * No array dedup needed: cbm_registry_add's exact-map check guarantees the QN * is new, and it calls this at most once per distinct key. */ -static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn, - bool is_test, CBMLanguage lang) { +static void index_under_name(cbm_registry_t *r, const char *key, const char *owned_qn, bool is_test, + CBMLanguage lang) { qn_array_t *arr = cbm_ht_get(r->by_name, key); if (!arr) { arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); @@ -3055,11 +3055,10 @@ static const char *swift_choose_receiver(const cbm_registry_t *r, const char **m } static const char *swift_choose_candidate(const cbm_registry_t *r, const char **matches, - const uint8_t *match_test, int matched, - const char *name, const CBMCall *call, - const char *module_qn, const char **import_vals, - int import_count, const char **scratch, - uint8_t *scratch_test) { + const uint8_t *match_test, int matched, const char *name, + const CBMCall *call, const char *module_qn, + const char **import_vals, int import_count, + const char **scratch, uint8_t *scratch_test) { const char *chosen = swift_choose_receiver(r, matches, match_test, matched, name, call, module_qn, import_vals, import_count, scratch, scratch_test); @@ -4781,8 +4780,8 @@ cbm_fuzzy_result_t cbm_registry_fuzzy_resolve(const cbm_registry_t *r, const cha if (have_imports) { fcount = filter_import_reachable((const char **)arr->items, qn_test_flags(arr), qn_lang_flags(arr), arr->count, import_map_vals, - import_map_count, filtered, filtered_flags, - filtered_langs, CBM_SZ_256); + import_map_count, filtered, filtered_flags, filtered_langs, + CBM_SZ_256); fptr = filtered; fflags = filtered_flags; flangs = filtered_langs; From 6519e1a797a1f6e2bc9001e99f552fcd470c4d1e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 20:28:54 +0100 Subject: [PATCH 13/16] fix(registry): rank test doubles below real code by an anchored path rule The extractor's verdict is the right primary signal, but Go projects keep many test doubles in ordinary, non-test files: generated fake clientsets, mock packages, mock_*.go files. The QN scan this branch removed happened to rank those below real code; the verdict alone does not, so calls from production code started resolving into them (gardener: about 570 call edges newly landed in fake or mock files, 73 of them from production code). cbm_registry_test_double_path is one registry-only predicate: a directory segment named exactly fake, mock, mocks, testing or testutil, or a file named mock_*.go or fake_*.go. It is anchored to whole segments and the file-name prefix, so inspect, latest, contest, fakeroot or mockery never match. All three registry seeders pass the extractor verdict OR this predicate on the path the definition's node stores (pass_definitions.c, pass_parallel.c, and the incremental seed in pipeline_incremental.c), so a full and an incremental index rank alike. register_and_link_def now takes the file's rel path for that, as pass_definitions.c already has it. The rule only orders candidates when a call is resolved. It stays out of cbm_is_test_path and cbm_is_test_file, so the stored is_test property, TESTS edges and importance do not change. Co-authored-by: Chad <4307099+BobbieBarker@users.noreply.github.com> Signed-off-by: Martin Vogel --- src/pipeline/pass_definitions.c | 7 ++++-- src/pipeline/pass_parallel.c | 13 ++++++---- src/pipeline/pipeline.h | 12 +++++++++ src/pipeline/pipeline_incremental.c | 5 +++- src/pipeline/registry.c | 38 +++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 9a212d319..44678b76c 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -521,13 +521,16 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const * 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. - * A generated member is registered by qualified name only, at all three. */ + * A generated member is registered by qualified name only, at all three. + * The registry's test verdict is the extractor's OR the test-double path + * rule (cbm_registry_test_double_path) on the node's file, at all three. */ if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) { 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, def->is_test); + def->label, lang, module_prefix_len, + def->is_test || cbm_registry_test_double_path(def_rel)); } if (def->qn_sig_off) { cbm_registry_set_swift_signature(ctx->registry, def->qualified_name, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index dddc18b80..4ae2ea88f 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1816,7 +1816,7 @@ int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, /* `file_node_id` is the defining file's node id (0 when it has none), looked up * once per file by the caller: computing the file QN and finding its node for * every definition was 700 k allocations and lookups on the Go corpus. */ -static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, +static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel, int64_t file_node_id, CBMLanguage lang, size_t module_prefix_len, int *reg_entries) { int edges = 0; @@ -1824,14 +1824,17 @@ 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, and - * for the generated member registered by qualified name only. */ + * (helpers.c) — see pass_definitions.c for the per-label rationale, for + * the generated member registered by qualified name only, and for the + * test-double path rule, judged on the path the def's node stores. */ if (cbm_label_is_registry_symbol(def->label)) { if (cbm_def_props_generated(def->extra_props)) { cbm_registry_add_qn_only(ctx->registry, def->qualified_name, def->label); } else { + const char *def_rel = def->file_path ? def->file_path : rel; cbm_registry_add_lang_in_module(ctx->registry, def->name, def->qualified_name, - def->label, lang, module_prefix_len, def->is_test); + def->label, lang, module_prefix_len, + def->is_test || cbm_registry_test_double_path(def_rel)); } if (def->qn_sig_off) { cbm_registry_set_swift_signature(ctx->registry, def->qualified_name, @@ -2050,7 +2053,7 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t : 0; for (int d = 0; d < result->defs.count; d++) { defines_edges += - register_and_link_def(ctx, &result->defs.items[d], file_node_id, + register_and_link_def(ctx, &result->defs.items[d], rel, file_node_id, files[i].language, py_prefix_len, ®_entries); } } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 80611ae17..83907ae4b 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -290,6 +290,18 @@ void cbm_registry_free(cbm_registry_t *r); void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name, const char *label, bool is_test); +/* The test-double path rule, for the registry's ranking only: true when the + * repo-relative path of a symbol's defining file has a directory segment named + * exactly `fake`, `mock`, `mocks`, `testing` or `testutil`, or its file name + * matches `mock_*.go` or `fake_*.go`. Go projects keep generated fake + * clientsets and mock packages in ordinary files the extractor rightly does + * not call tests; a call should still prefer real code over them. The three + * registry seeders (pass_definitions.c, pass_parallel.c, + * pipeline_incremental.c) pass the extractor's verdict OR this, so full and + * incremental indexes rank alike. It never decides what a node is: the stored + * is_test property, TESTS edges and importance stay with cbm_is_test_path. */ +bool cbm_registry_test_double_path(const char *rel_path); + /* Swift-only metadata and label-compatible overload selection. The returned * QNs are borrowed from the registry and all belong to one base callable. * -1 means this name has Swift definitions but no compatible overload. */ diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 80e6d906e..9324314cd 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1396,8 +1396,11 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) { size_t prefix_len = lang == CBM_LANG_PYTHON ? cbm_pipeline_fqn_symbol_prefix_len(seed->project, node->file_path) : 0; + /* The full index's verdict: the extractor's (stored as is_test) OR the + * test-double path rule on the node's file (pass_definitions.c). */ + bool is_test = cbm_node_is_test(node) || cbm_registry_test_double_path(node->file_path); cbm_registry_add_lang_in_module(seed->registry, node->name, node->qualified_name, node->label, - lang, prefix_len, cbm_node_is_test(node)); + lang, prefix_len, is_test); if (node->properties_json) { const char *mask = strstr(node->properties_json, "\"param_default_mask\":\""); const char *count = strstr(node->properties_json, "\"selector_param_count\":"); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 2b93bc93c..ac1b8ce61 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -205,6 +205,44 @@ static int common_prefix_len(const char *a, const char *b) { enum { REG_TEST_PENALTY = 1000 }; +/* A directory segment that, by name alone, holds test doubles: Go's generated + * fake clientsets and listers (pkg/client/.../fake), mock packages, and the + * testing / testutil helper packages. Whole segments only, compared exactly. */ +static bool test_double_dir_segment(const char *seg, size_t len) { + static const char *const dirs[] = {"fake", "mock", "mocks", "testing", "testutil"}; + for (size_t i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) { + if (strlen(dirs[i]) == len && memcmp(seg, dirs[i], len) == 0) { + return true; + } + } + return false; +} + +/* `base` is a Go file named `prefix`*.go. */ +static bool go_file_with_prefix(const char *base, const char *prefix) { + static const char go_ext[] = ".go"; + size_t blen = strlen(base); + size_t plen = strlen(prefix); + size_t elen = sizeof(go_ext) - SKIP_ONE; + return blen >= plen + elen && strncmp(base, prefix, plen) == 0 && + strcmp(base + blen - elen, go_ext) == 0; +} + +bool cbm_registry_test_double_path(const char *rel_path) { + if (!rel_path) { + return false; + } + const char *seg = rel_path; + for (const char *slash = strchr(seg, '/'); slash; slash = strchr(seg, '/')) { + if (test_double_dir_segment(seg, (size_t)(slash - seg))) { + return true; + } + seg = slash + SKIP_ONE; + } + /* `seg` is the file name now. */ + return go_file_with_prefix(seg, "mock_") || go_file_with_prefix(seg, "fake_"); +} + /* The stored test verdicts. index_under_name drops an entry it cannot record a * verdict for, so this covers every entry of a non-empty bucket. */ static const uint8_t *qn_test_flags(const qn_array_t *arr) { From 7e9186cf8ac9befda5378698364fba5ab7a326e2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 20:51:42 +0100 Subject: [PATCH 14/16] fix(incremental): carry the test verdict on closure-repair proxies The incremental registry seed reads each stored node's test verdict back with cbm_node_is_test, as the full build takes CBMDefinition.is_test. Main's closure-repair route seeds that registry from cbm_delta_preseed proxies, which load only the properties a resolver reads, and is_test was not among them: every proxy scored as production, so a re-resolved caller could bind a test helper the full build ranks last. On gardener a comment-only edit of three files made the incremental index differ from a fresh full one by two ranking edges (a WRITES into a _test.go function, a USAGE into pkg/utils/test); with this change the only differences left are the 13 similarity / OVERRIDE / IMPLEMENTS edges main already shows on that edit. The preseed now selects whether a row's properties carry "is_test":true and adds just that member to the proxy's properties. Proxies are never written back. If adding it cannot be allocated, the preseed fails like an id mismatch does, rather than seed a proxy that would rank as production. Co-authored-by: Chad <4307099+BobbieBarker@users.noreply.github.com> Signed-off-by: Martin Vogel --- src/pipeline/pipeline_delta.c | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 63c5ef1d1..22573f8c8 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -623,6 +623,28 @@ static char *delta_generated_props(const char *props, const char *generated) { return out; } +/* `props` (a JSON object) with the extractor's test verdict added as + * `"is_test":true` (CBM_MEM_CLASS_OTHER; the caller frees), NULL on allocation + * failure. The incremental registry seed reads the verdict back + * (cbm_node_is_test) so a re-resolved caller ranks a test definition below + * real code exactly as the full build does; an absent key reads as false. */ +static char *delta_test_props(const char *props) { + static const char member[] = "\"is_test\":true"; + size_t pl = props ? strlen(props) : 0; + bool has_members = pl > PAIR_LEN && props[0] == '{' && props[pl - SKIP_ONE] == '}'; + size_t cap = pl + sizeof(member) + CBM_SZ_4; + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, cap); + if (!out) { + return NULL; + } + if (has_members) { + snprintf(out, cap, "%.*s,%s}", (int)(pl - SKIP_ONE), props, member); + } else { + snprintf(out, cap, "{%s}", member); + } + 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) { @@ -673,7 +695,13 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * 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. */ + * without it the proxy came back into the short-name index. + * + * And the extractor's test verdict: the registry seed reads it back + * (cbm_node_is_test) to rank a test definition below real code, as the + * full build does with CBMDefinition.is_test; without it every proxy + * scored as production and a re-resolved caller could bind a test helper + * the full build ranks last. */ sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2( db, @@ -702,7 +730,8 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g " 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" + " THEN json_extract(properties, '$." CBM_PROP_GENERATED "') END," + " instr(properties, '\"is_test\":true') > 0" " FROM nodes" " WHERE project = ?1 AND label NOT IN" " ('Macro','Comment','Section','Branch','Commit','Tag')" @@ -727,14 +756,27 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g 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); + bool is_test = sqlite3_column_int(stmt, 10) != 0; 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); + const char *seed_props = generated_props ? generated_props : props; + char *test_props = is_test ? delta_test_props(seed_props) : NULL; + if (is_test && !test_props) { + /* A proxy without its verdict would rank as production code; + * the run cannot be trusted, as with an id mismatch below. */ + cbm_free(CBM_MEM_CLASS_OTHER, generated_props); + cbm_free(CBM_MEM_CLASS_OTHER, owner_props); + sqlite3_finalize(stmt); + cbm_log_error("delta.preseed_verdict_alloc_failed", "qn", qn ? qn : ""); + return -1; + } /* 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, generated_props ? generated_props : props); + end_line, test_props ? test_props : seed_props); + cbm_free(CBM_MEM_CLASS_OTHER, test_props); cbm_free(CBM_MEM_CLASS_OTHER, generated_props); cbm_free(CBM_MEM_CLASS_OTHER, owner_props); if (got != id) { From cf4f117db469e9d3d456631a927d416a2fedd04d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 11 Nov 2026 20:51:42 +0100 Subject: [PATCH 15/16] test(registry): pin the test-double rule and the verdict through full and incremental - registry_test_double_path_table: the anchored predicate's positives (fake, mock, mocks, testing, testutil directory segments; mock_*.go, fake_*.go) and negatives (inspect, latest, contest, fakeroot, mockery, testingutil, mocking.go, mock_store.py, a file named fake, a different case, NULL). - registry_swift_overload_choice_uses_stored_verdicts: the Swift overload choosers score with the verdicts stored at registration; without them the smaller QN, a test double, wins the tie. - pipeline_registry_ranks_by_extractor_verdict_full_and_incremental: the `latest` vs `_test.go` misranking the QN scan made, through a full index, a closure-repair incremental run and a forced legacy-partial run. - pipeline_registry_ranks_test_doubles_last_full_and_incremental: a generated fake that out-ranks the real package on proximity, through the same three runs. Each fails with the piece it pins reverted (predicate, seeder verdict, incremental seeder rule, closure-repair verdict carry, Swift verdict carry). Co-authored-by: Chad <4307099+BobbieBarker@users.noreply.github.com> Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 128 ++++++++++++++++++++++++++++++++++++++++++ tests/test_registry.c | 64 +++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 55a4ac617..baf2e34b7 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -6619,6 +6619,132 @@ TEST(pipeline_incremental_cross_file_call_reference_matches_fresh_full) { PASS(); } +/* #2427 rank probes: one bare call, two same-named candidates, and the + * registry's test ranking alone decides which one the call binds. Three runs + * on one database: a full index, then a comment-only edit of the caller on + * the closure-repair route, then another on the forced legacy-partial route. + * Both incremental routes re-resolve the caller against a registry seeded from + * stored nodes rather than the extractor's definitions, which is where a + * seeder that loses the verdict shows. */ +typedef struct { + int want[3]; + int other[3]; + cbm_incremental_route_t route[3]; +} rank_probe_t; + +static int rank_probe_count(const char *db, const char *project, const char *source, + const char *target, const char *file) { + cbm_store_t *s = cbm_store_open_path(db); + if (!s) { + return -1; + } + int n = named_edge_to_file_count(s, project, "CALLS", source, target, file); + cbm_store_close(s); + return n; +} + +static void rank_probe(const char *tmp, const char *caller_path, const char *caller_src, + const char *source, const char *target, const char *want_file, + const char *other_file, rank_probe_t *out) { + char db[512]; + snprintf(db, sizeof(db), "%s/rank.db", tmp); + for (int run = 0; run < 3; run++) { + out->want[run] = -1; + out->other[run] = -1; + out->route[run] = CBM_INCREMENTAL_ROUTE_NONE; + if (run > 0) { + char edited[512]; + snprintf(edited, sizeof(edited), "%s// edit %d\n", caller_src, run); + write_temp_file(tmp, caller_path, edited); + } + cbm_pipeline_incremental_test_reset_faults(); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + if (run == 2) { + cbm_pipeline_incremental_test_force_legacy_partial_once(); + } +#endif + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL); + if (!p) { + return; + } + int rc = cbm_pipeline_run(p); + out->route[run] = cbm_pipeline_incremental_test_last_route(); + char project[256]; + snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + if (rc != 0) { + return; + } + out->want[run] = rank_probe_count(db, project, source, target, want_file); + out->other[run] = rank_probe_count(db, project, source, target, other_file); + } +} + +/* "latest" spells "test", so the QN scan the extractor's verdict replaced + * ranked the production Parse below a helper in a _test.go file, whose Go QN + * names only its package and so spells nothing. The verdict ranks them the + * right way round, and both incremental routes must read the same verdict + * back from the stored nodes: the closure-repair proxies once carried no + * is_test, every candidate scored as production, and the smaller QN -- the + * test helper -- won. */ +TEST(pipeline_registry_ranks_by_extractor_verdict_full_and_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rank_verdict_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + static const char caller[] = "package app\n\nfunc Run() int { return Parse() }\n"; + write_temp_file(tmp, "go.mod", "module example.com/rank\n\ngo 1.22\n"); + write_temp_file(tmp, "latest/parse.go", "package latest\n\nfunc Parse() int { return 1 }\n"); + write_temp_file(tmp, "harness/parse_test.go", + "package harness\n\nfunc Parse() int { return 2 }\n"); + write_temp_file(tmp, "app/run.go", caller); + rank_probe_t r; + rank_probe(tmp, "app/run.go", caller, "Run", "Parse", "latest/parse.go", + "harness/parse_test.go", &r); + th_rmtree(tmp); + ASSERT_EQ(r.want[0], 1); /* full */ + ASSERT_EQ(r.other[0], 0); + ASSERT_EQ(r.route[1], CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_EQ(r.want[1], 1); + ASSERT_EQ(r.other[1], 0); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + ASSERT_EQ(r.route[2], CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); +#endif + ASSERT_EQ(r.want[2], 1); + ASSERT_EQ(r.other[2], 0); + PASS(); +} + +/* A generated fake beside its callers: client/fake shares more of the caller's + * package path than the real store does, so on the extractor's verdict alone + * (a fake is not a test file) the fake wins on proximity. The test-double path + * rule ranks it last in the full index and, through the incremental seed's own + * use of the rule, on both incremental routes. */ +TEST(pipeline_registry_ranks_test_doubles_last_full_and_incremental) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rank_double_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + static const char caller[] = "package client\n\nfunc Run() int { return Load() }\n"; + write_temp_file(tmp, "go.mod", "module example.com/rank\n\ngo 1.22\n"); + write_temp_file(tmp, "store/load.go", "package store\n\nfunc Load() int { return 1 }\n"); + write_temp_file(tmp, "client/fake/load.go", "package fake\n\nfunc Load() int { return 2 }\n"); + write_temp_file(tmp, "client/run.go", caller); + rank_probe_t r; + rank_probe(tmp, "client/run.go", caller, "Run", "Load", "store/load.go", "client/fake/load.go", + &r); + th_rmtree(tmp); + ASSERT_EQ(r.want[0], 1); /* full */ + ASSERT_EQ(r.other[0], 0); + ASSERT_EQ(r.route[1], CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_EQ(r.want[1], 1); + ASSERT_EQ(r.other[1], 0); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + ASSERT_EQ(r.route[2], CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); +#endif + ASSERT_EQ(r.want[2], 1); + ASSERT_EQ(r.other[2], 0); + PASS(); +} + /* A semantic control file can change an unchanged source file's exact target. * Both target modules deliberately export the same short name: without the * tsconfig path mapping, project-wide unique-name fallback is ambiguous and @@ -34486,6 +34612,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_call_reference_sequential_parallel_edge_set_parity); RUN_TEST(pipeline_complexity_props_independent_of_worker_order); RUN_TEST(pipeline_incremental_cross_file_call_reference_matches_fresh_full); + RUN_TEST(pipeline_registry_ranks_by_extractor_verdict_full_and_incremental); + RUN_TEST(pipeline_registry_ranks_test_doubles_last_full_and_incremental); RUN_TEST(pipeline_incremental_changed_target_invalidates_stale_inbound_call_reference); RUN_TEST(pipeline_incremental_parallel_registry_nodes_advance_shared_ids); #if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API diff --git a/tests/test_registry.c b/tests/test_registry.c index 99bb0b523..10c9ef8e5 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -243,6 +243,68 @@ TEST(registry_sole_test_candidate_still_resolves) { PASS(); } +/* The test-double path rule: whole directory segments named fake, mock, mocks, + * testing or testutil, and Go files named mock_*.go or fake_*.go. Anchored, so + * the substrings the old QN scan tripped on (inspect, latest, contest) and + * near-miss names (fakeroot, mockery, testingutil) stay ordinary code. */ +TEST(registry_test_double_path_table) { + static const char *const doubles[] = { + "pkg/client/fake/clientset.go", + "fake/x.go", + "internal/mocks/store.go", + "pkg/mock/store.go", + "pkg/testing/fixtures.go", + "internal/testutil/helpers.go", + "pkg/mock_store.go", + "pkg/fake_clock.go", + "mock_store.go", + "src/fake/deep/nested/util.py", + }; + static const char *const ordinary[] = { + "pkg/inspect/x.go", "pkg/latest/x.go", "pkg/contest/x.go", "pkg/fakeroot/x.go", + "pkg/mockery/x.go", "pkg/testingutil/x.go", "pkg/mocking.go", "pkg/mock_store.py", + "pkg/fake_clock.golang", "pkg/fake.go", "pkg/x/fake", "pkg/Fake/x.go", + "pkg/myfake/x.go", "pkg/fake-client/x.go", "testing.go", "", + }; + char why[256]; + for (size_t i = 0; i < sizeof(doubles) / sizeof(doubles[0]); i++) { + if (!cbm_registry_test_double_path(doubles[i])) { + snprintf(why, sizeof(why), "expected a test-double path: %s", doubles[i]); + FAIL(why); + } + } + for (size_t i = 0; i < sizeof(ordinary) / sizeof(ordinary[0]); i++) { + if (cbm_registry_test_double_path(ordinary[i])) { + snprintf(why, sizeof(why), "expected an ordinary path: %s", ordinary[i]); + FAIL(why); + } + } + ASSERT_FALSE(cbm_registry_test_double_path(NULL)); + PASS(); +} + +/* The Swift overload choosers score with the verdicts stored at registration, + * carried through their own filtered lists. Both overloads fit the call and tie + * on proximity; without the verdicts the smaller QN (the test double) would win + * the tie. */ +TEST(registry_swift_overload_choice_uses_stored_verdicts) { + cbm_registry_t *r = cbm_registry_new(); + const char *double_qn = "proj.Fixtures.Store.load(path:String)"; + const char *real_qn = "proj.Zeta.Store.load(path:String)"; + cbm_registry_add(r, "load", double_qn, "Method", true); + cbm_registry_add(r, "load", real_qn, "Method", false); + cbm_registry_set_swift_signature(r, double_qn, 0, 1, NULL); + cbm_registry_set_swift_signature(r, real_qn, 0, 1, NULL); + + CBMCallArg arg = {.keyword = "path"}; + CBMCall call = {.callee_name = "load", .args = &arg, .arg_count = 1}; + const char *out[4] = {0}; + ASSERT_EQ(cbm_registry_swift_candidates(r, &call, "proj.Caller", NULL, 0, out, 4), 1); + ASSERT_STR_EQ(out[0], real_qn); + cbm_registry_free(r); + PASS(); +} + TEST(registry_label_of) { cbm_registry_t *r = cbm_registry_new(); cbm_registry_add(r, "Foo", "proj.pkg.Foo", "Class", false); @@ -3382,6 +3444,8 @@ SUITE(registry) { RUN_TEST(registry_prefers_non_test_over_test_candidate); RUN_TEST(registry_test_verdict_comes_from_the_caller_not_the_qn); RUN_TEST(registry_sole_test_candidate_still_resolves); + RUN_TEST(registry_test_double_path_table); + RUN_TEST(registry_swift_overload_choice_uses_stored_verdicts); RUN_TEST(registry_label_of); RUN_TEST(registry_value_target_field_twin); RUN_TEST(registry_find_by_name); From 59876296829f305fe1b02f097ee4dc7d53be7d7a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 12 Nov 2026 00:14:49 +0100 Subject: [PATCH 16/16] ci(msan): lower mmap ASLR entropy on the host before the MSan suite Since the hosted runner image changed on 2026-11-11 the MSan lane aborts before any test runs: MemorySanitizer: CHECK failed: msan_linux.cpp:193 "((personality(old_personality | ADDR_NO_RANDOMIZE))) != ((-1))" The runner kernel now randomizes mmap with more bits than MSan's shadow layout tolerates, so the runtime tries to re-exec itself with ADDR_NO_RANDOMIZE; inside the lane's container the default seccomp profile refuses that personality() call. The last green MSan run was 2026-11-11 01:28Z (run 42939151247); every PR run with the lane since 18:45Z failed this way (runs 43080749803, 43081615012), independent of the change under test. The TSan leg hit the same kernel behaviour earlier and lowers vm.mmap_rnd_bits to 28 on Linux before its suite. Do the same on the host before the MSan container starts; the container shares the host kernel, so MSan's layout fits again without disabling ASLR in the container. `|| true` keeps the step from failing where sysctl is not permitted, exactly as in the TSan step. Signed-off-by: Martin Vogel --- .github/workflows/_test.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 0eddc1754..e1db65e32 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -460,7 +460,14 @@ jobs: run: scripts/ci/msan-lane.sh build - name: MSan suite (exclusions per scripts/msan.sh) - run: scripts/ci/msan-lane.sh run + # Reduce mmap ASLR entropy on the host first, as the TSan leg does: with + # the runner kernel's higher default the MSan runtime tries to re-exec + # itself with ADDR_NO_RANDOMIZE, which the container's seccomp profile + # refuses, and it aborts ("CHECK failed: msan_linux.cpp:193 ... + # personality") before any test runs. The container shares this kernel. + run: | + sudo sysctl -w vm.mmap_rnd_bits=28 || true + scripts/ci/msan-lane.sh run # macOS leak coverage. LeakSanitizer runs by default under ASan on Linux, so # test-unix's ubuntu legs have always had it; on macOS it is off by default