From 7d3e09baf8b132a140ded36443382a8b54530986 Mon Sep 17 00:00:00 2001 From: knewstimek Date: Sun, 4 Oct 2026 13:00:18 +0900 Subject: [PATCH] fix(cpp-lsp): use direct includes to disambiguate receiver targets Prefer a unique receiver consistent with the current module or a direct quoted relative header include. Preserve namespace boundaries and propagate exact-site ambiguity into CALLS materialization. Cover references, pointers, ties and parallel extraction with synthetic regressions. Signed-off-by: knewstimek --- internal/cbm/lsp/c_lsp.c | 130 +++++++++++++++++++++++++----- src/pipeline/pass_lsp_cross.c | 38 +++++++++ tests/test_c_lsp.c | 50 ++++++++++++ tests/test_cpp_duplicate_calls.py | 130 ++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 tests/test_cpp_duplicate_calls.py diff --git a/internal/cbm/lsp/c_lsp.c b/internal/cbm/lsp/c_lsp.c index 74b97d69d9..b24003aa64 100644 --- a/internal/cbm/lsp/c_lsp.c +++ b/internal/cbm/lsp/c_lsp.c @@ -2714,8 +2714,17 @@ static void c_neg_memo_free(CLSPContext *ctx) { ctx->neg_memo_count = 0; } +/* Match a module boundary, not a textual prefix (mod != mod_extra). */ +static bool c_type_in_module(const char *qn, const char *module) { + if (!module || !module[0]) + return false; + size_t len = strlen(module); + return strncmp(qn, module, len) == 0 && qn[len] == '.'; +} + static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const char *type_qn, - const char *member_name, int depth) { + const char *member_name, int depth, + bool *ambiguous_out) { if (!type_qn || !member_name) return NULL; if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) @@ -2730,10 +2739,10 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch // Negative-lookup memo (depth==0, shared read-only registry only). A recorded // hit means this exact (type_qn, member) already failed the whole cascade. Under - // the sealed Tier-2 registry the module-prefix (below), base-class and short-name - // cascades are pure, immutable functions of (type_qn, registry), so they are - // provably still NULL and can be skipped. Only the SCOPE-ALIAS path is - // context-dependent, so it is still evaluated below before we trust the memo. + // the sealed Tier-2 registry and this file's fixed module/include mappings, + // the module-prefix, base-class and short-name cascades remain unchanged. + // Only the SCOPE-ALIAS path can change during the walk, so it is still + // evaluated below before we trust the memo. // The direct lookup above already served as the collision/staleness verification. bool neg_memo_hit = false; uint64_t neg_h = 0; @@ -2764,7 +2773,7 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch if (underlying && !cbm_type_is_unknown(underlying)) { const char *alias_target_qn = type_to_qn(underlying); if (alias_target_qn) { - f = c_lookup_member_depth(ctx, alias_target_qn, member_name, depth + 1); + f = c_lookup_member_depth(ctx, alias_target_qn, member_name, depth + 1, NULL); if (f) return f; } @@ -2773,7 +2782,7 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch } // Memo hit and the scope-alias path also missed: the remaining base-class and - // short-name cascades are registry-only and provably NULL → skip them (this is + // short-name cascades are unchanged for this file and still NULL → skip them (this is // where the O(type_count) short-name scan is avoided). Idempotent re-insert is // unnecessary since neg_h is already present. if (neg_memo_hit) @@ -2791,7 +2800,7 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch if (rt) { // Alias chain if (rt->alias_of) { - f = c_lookup_member_depth(ctx, rt->alias_of, member_name, depth + 1); + f = c_lookup_member_depth(ctx, rt->alias_of, member_name, depth + 1, NULL); if (f) return f; } @@ -2799,7 +2808,7 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch // Base classes (embedded_types stores base class QNs) if (rt->embedded_types) { for (int i = 0; rt->embedded_types[i]; i++) { - f = c_lookup_member_depth(ctx, rt->embedded_types[i], member_name, depth + 1); + f = c_lookup_member_depth(ctx, rt->embedded_types[i], member_name, depth + 1, NULL); if (f) return f; } @@ -2812,13 +2821,21 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch * file-scoped ".Logger" or the bare "Logger" (e.g. the return type of * a namespace-scoped factory used outside that namespace). Resolve by the * SHORT name (last segment) against the registry and retry with the full QN. - * Reached only after the direct/module/alias/base lookups all miss; prefers - * an in-module match. Mirrors the C# short-name type fallback. */ + * Reached only after the direct/module/alias/base lookups all miss. Prefer + * the current module, then literal relative includes, then a globally unique + * candidate. Equal-rank distinct QNs stay unresolved, independent of order. */ if (depth == 0 && ctx->registry) { const char *dot = strrchr(type_qn, '.'); const char *shortn = dot ? dot + 1 : type_qn; size_t slen = strlen(shortn); + const char *type_suffix = type_qn; + if (c_type_in_module(type_qn, ctx->module_qn)) + type_suffix += strlen(ctx->module_qn) + 1; + size_t suffix_len = strlen(type_suffix); const char *best_qn = NULL; + int best_rank = -1; + bool ambiguous = false; + bool excluded_scope = false; CBMTypeShortIter it; cbm_registry_types_by_short_name_chain(ctx->registry, shortn, &it); int i; @@ -2835,13 +2852,41 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch if (strcmp(q, type_qn) == 0) { continue; // already tried as-is above } - best_qn = q; - if (ctx->module_qn && strncmp(q, ctx->module_qn, strlen(ctx->module_qn)) == 0) { - break; // prefer a match in the current module + // Preserve namespace qualification when comparing included types. + // Preserve the legacy cross-file short-name cascade without an + // include map, where a QN may carry a different file's prefix. + if (ctx->include_count > 0 && suffix_len > slen && + (qlen <= suffix_len || q[qlen - suffix_len - 1] != '.' || + strcmp(q + qlen - suffix_len, type_suffix) != 0)) { + excluded_scope = true; + continue; + } + int rank = c_type_in_module(q, ctx->module_qn) ? 4 : 0; + for (int j = 0; j < ctx->include_count; j++) { + const char *included = ctx->include_ns_qns[j]; + if (c_type_in_module(q, included)) { + // A directly named type in the header outranks its + // namespaced short-name homonyms. + int included_rank = strcmp(q + strlen(included) + 1, type_suffix) == 0 ? 3 : 1; + if (included_rank > rank) + rank = included_rank; + } } + if (rank > best_rank) { + best_qn = q; + best_rank = rank; + ambiguous = false; + } else if (rank == best_rank && strcmp(best_qn, q) != 0) { + ambiguous = true; + } + } + if (ambiguous || (!best_qn && excluded_scope)) { + if (ambiguous_out) + *ambiguous_out = true; + return NULL; // Do not memoize: callers need the ambiguity diagnostic. } if (best_qn) { - f = c_lookup_member_depth(ctx, best_qn, member_name, depth + 1); + f = c_lookup_member_depth(ctx, best_qn, member_name, depth + 1, NULL); if (f) { return f; } @@ -2857,7 +2902,7 @@ static const CBMRegisteredFunc *c_lookup_member_depth(CLSPContext *ctx, const ch const CBMRegisteredFunc *c_lookup_member(CLSPContext *ctx, const char *type_qn, const char *member_name) { - return c_lookup_member_depth(ctx, type_qn, member_name, 0); + return c_lookup_member_depth(ctx, type_qn, member_name, 0, NULL); } // True if any BASE class of type_qn (not type_qn itself) declares member_name — @@ -3990,9 +4035,16 @@ static void c_resolve_calls_in_node_inner(CLSPContext *ctx, TSNode node) { // Use type-aware overload scoring const CBMRegisteredFunc *method = cbm_registry_lookup_method_by_types( ctx->registry, type_qn, field_name, arg_types, arg_count); - // Fall back to c_lookup_member for base class traversal + // Keep ambiguous receiver identity distinct from an ordinary miss. + bool ambiguous = false; if (!method) - method = c_lookup_member(ctx, type_qn, field_name); + method = + c_lookup_member_depth(ctx, type_qn, field_name, 0, &ambiguous); + if (!method && ambiguous) { + c_emit_resolved_call_orig_at(ctx, field_name, NULL, + "lsp_ambiguous_receiver", 0.0f, node); + goto recurse; + } if (ctx->debug) fprintf(stderr, " [clsp] member call result: %s\n", method ? method->qualified_name : "NULL"); @@ -5425,6 +5477,45 @@ static void c_process_class(CLSPContext *ctx, TSNode class_node) { // Process file: top-level walk // ============================================================================ +/* Only top-level literal quoted includes with an unambiguous relative spelling. + * Module QNs omit the extension. Do not guess include search paths, macros, + * parent traversal, conditional includes, or dotted path components. */ +static void c_collect_relative_include(CLSPContext *ctx, TSNode node) { + if (!ctx->module_qn || strcmp(ts_node_type(node), "preproc_include") != 0) + return; + TSNode path_node = ts_node_child_by_field_name(node, "path", 4); + if (ts_node_is_null(path_node) || strcmp(ts_node_type(path_node), "string_literal") != 0) + return; + char *path = c_node_text(ctx, path_node); + size_t len = path ? strlen(path) : 0; + if (len < 4 || path[0] != '"' || path[len - 1] != '"') + return; + path[len - 1] = '\0'; + char *relative = path + 1; + if (relative[0] == '/' || relative[0] == '\\' || strchr(relative, ':')) + return; + char *ext = strrchr(relative, '.'); + if (!ext || (strcmp(ext, ".h") != 0 && strcmp(ext, ".hpp") != 0 && strcmp(ext, ".hh") != 0 && + strcmp(ext, ".hxx") != 0)) + return; + *ext = '\0'; + if (!relative[0] || strchr(relative, '.')) + return; + for (char *p = relative; *p; p++) { + if (*p == '/' || *p == '\\') { + if (p == relative || p[-1] == '.' || !p[1]) + return; + *p = '.'; + } + } + const char *dot = strrchr(ctx->module_qn, '.'); + if (!dot) + return; + const char *module = cbm_arena_sprintf(ctx->arena, "%.*s.%s", (int)(dot - ctx->module_qn), + ctx->module_qn, relative); + c_lsp_add_include(ctx, relative, module); +} + void c_lsp_process_file(CLSPContext *ctx, TSNode root) { if (ts_node_is_null(root)) return; @@ -5436,6 +5527,9 @@ void c_lsp_process_file(CLSPContext *ctx, TSNode root) { TSNode child; // Hoisted: prevents ASan stack-use-after-scope between passes TSNode inner; + for (uint32_t i = 0; i < kn; i++) + c_collect_relative_include(ctx, kids[i]); + // Pass 1: process using declarations and global variables for (uint32_t i = 0; i < kn; i++) { child = kids[i]; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 3b74cc1ebb..04f99f1d4e 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -1276,6 +1276,41 @@ static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs * directly across N files (test_incremental.c saw 3.5 GB peak on a * 1100-file repo before this fix). Output gets copied into the file's own * arena and merged into result->resolved_calls. */ +/* Preserve a negative receiver-identity decision at the exact raw/preprocessed + * occurrence. Both sequential and fused-parallel CALLS passes already honor + * requires_lsp_resolution; unrelated unresolved calls keep their fallback. */ +static void pxc_require_unambiguous_receivers(CBMFileResult *result, + const CBMResolvedCallArray *out) { + CBMArena keys; + cbm_arena_init(&keys); + CBMHashTable *blocked = NULL; + for (int i = 0; i < out->count; i++) { + const CBMResolvedCall *r = &out->items[i]; + if (!r->strategy || strcmp(r->strategy, "lsp_ambiguous_receiver") != 0 || + r->kind != CBM_RESOLVED_INVOCATION || !r->caller_qn || + r->site_end_byte <= r->site_start_byte) + continue; + if (!blocked) + blocked = cbm_ht_create((uint32_t)(out->count + 1)); + char *key = cbm_arena_sprintf(&keys, "%s:%u:%u:%u", r->caller_qn, r->site_start_byte, + r->site_end_byte, (unsigned)r->source_origin); + cbm_ht_set(blocked, key, (void *)1); + } + for (int i = 0; blocked && i < result->calls.count; i++) { + CBMCall *call = &result->calls.items[i]; + if (!call->enclosing_func_qn || call->site_end_byte <= call->site_start_byte) + continue; + char *key = + cbm_arena_sprintf(&keys, "%s:%u:%u:%u", call->enclosing_func_qn, call->site_start_byte, + call->site_end_byte, (unsigned)call->source_origin); + if (cbm_ht_get(blocked, key)) + call->requires_lsp_resolution = true; + } + if (blocked) + cbm_ht_free(blocked); + cbm_arena_destroy(&keys); +} + void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int source_len, const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count) { @@ -1337,6 +1372,7 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int break; } + pxc_require_unambiguous_receivers(r, &out); pxc_append_results(&r->arena, &r->resolved_calls, &out); pxc_append_synthetic_calls(&r->arena, &r->calls, &synthetic_calls); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); @@ -1356,6 +1392,7 @@ void cbm_pxc_run_one_ts(CBMFileResult *r, const char *source, int source_len, co cbm_run_ts_lsp_cross(&scratch, source, source_len, module_qn, js_mode, jsx_mode, dts_mode, defs, def_count, imp_names, imp_qns, imp_count, r->cached_tree, &out); + pxc_require_unambiguous_receivers(r, &out); pxc_append_results(&r->arena, &r->resolved_calls, &out); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); } @@ -1517,6 +1554,7 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char * break; } if (used_prebuilt) { + pxc_require_unambiguous_receivers(result, &out); pxc_append_results(&result->arena, &result->resolved_calls, &out); pxc_append_synthetic_calls(&result->arena, &result->calls, &synthetic_calls); } diff --git a/tests/test_c_lsp.c b/tests/test_c_lsp.c index 933cacc36f..cd2000e5f8 100644 --- a/tests/test_c_lsp.c +++ b/tests/test_c_lsp.c @@ -15677,6 +15677,55 @@ TEST(registry_short_name_indexes) { PASS(); } +/* Synthetic duplicate receiver candidates: include provenance, module + * boundaries, explicit namespace and insertion order must all be respected. */ +TEST(clsp_duplicate_receiver_include_selection) { + for (int reverse = 0; reverse < 2; reverse++) { + CBMArena arena; + cbm_arena_init(&arena); + CBMTypeRegistry reg; + cbm_registry_init(®, &arena); + const char *qns[] = { + "test.fixture_latch.FixtureLatch", "test.mirror.fixture_latch.FixtureLatch", + "test.fixture_latch_extra.FixtureLatch", "test.fixture_latch.North.FixtureLatch", + "test.fixture_latch.South.FixtureLatch"}; + for (int n = 0; n < 5; n++) { + int i = reverse ? 4 - n : n; + CBMRegisteredType t = {0}; + t.qualified_name = qns[i]; + t.short_name = "FixtureLatch"; + cbm_registry_add_type(®, t); + CBMRegisteredFunc f = {0}; + f.qualified_name = cbm_arena_sprintf(&arena, "%s.Acquire", qns[i]); + f.receiver_type = qns[i]; + f.short_name = "Acquire"; + cbm_registry_add_func(®, f); + } + cbm_registry_finalize(®); + CLSPContext ctx; + CBMResolvedCallArray out = {0}; + c_lsp_init(&ctx, &arena, "", 0, ®, "test.caller", true, &out); + ASSERT_NULL(c_lookup_member(&ctx, "test.caller.FixtureLatch", "Acquire")); + c_lsp_add_include(&ctx, "fixture_latch.h", "test.fixture_latch"); + const CBMRegisteredFunc *f = c_lookup_member(&ctx, "North.FixtureLatch", "Acquire"); + ASSERT_NOT_NULL(f); + ASSERT_STR_EQ(f->qualified_name, "test.fixture_latch.North.FixtureLatch.Acquire"); + f = c_lookup_member(&ctx, "test.caller.South.FixtureLatch", "Acquire"); + ASSERT_NOT_NULL(f); + ASSERT_STR_EQ(f->qualified_name, "test.fixture_latch.South.FixtureLatch.Acquire"); + ASSERT_NULL(c_lookup_member(&ctx, "Absent.FixtureLatch", "Acquire")); + // Directly declared FixtureLatch outranks namespaced homonyms and the + // textual-prefix sibling fixture_latch_extra. + f = c_lookup_member(&ctx, "test.caller.FixtureLatch", "Acquire"); + ASSERT_NOT_NULL(f); + ASSERT_STR_EQ(f->qualified_name, "test.fixture_latch.FixtureLatch.Acquire"); + c_lsp_add_include(&ctx, "mirror/fixture_latch.h", "test.mirror.fixture_latch"); + ASSERT_NULL(c_lookup_member(&ctx, "test.caller.FixtureLatch", "Acquire")); + cbm_arena_destroy(&arena); + } + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* C++ sibling guard: the same shared-registry read-only invariant for the @@ -16386,6 +16435,7 @@ TEST(clsp_preprocessed_destructor_rewrite_respects_origin_during_rewrite) { } SUITE(c_lsp) { + RUN_TEST(clsp_duplicate_receiver_include_selection); RUN_TEST(clsp_c_reassigned_function_pointer_calls_join_exact_occurrences); RUN_TEST(clsp_c_nested_function_pointer_shadow_restores_outer_target); RUN_TEST(clsp_cpp_repeated_same_leaf_calls_join_exact_occurrences); diff --git a/tests/test_cpp_duplicate_calls.py b/tests/test_cpp_duplicate_calls.py new file mode 100644 index 0000000000..b8c127561a --- /dev/null +++ b/tests/test_cpp_duplicate_calls.py @@ -0,0 +1,130 @@ +"""Bounded real-MCP regression for duplicate C++ receiver definitions. + +Run: python tests/test_cpp_duplicate_calls.py [--report ] +Fixtures and graph caches are fresh beneath the user's cache directory. +""" +import argparse +import hashlib +import json +import pathlib +import sys +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).parent / "windows")) +from mcp_stdio import McpServer + +HEADER = "#pragma once\nclass FixtureLatch { public: bool Acquire(); void AcquireAgain(); };\n" +IMPL = '#include "fixture_latch.h"\nbool FixtureLatch::Acquire() { return true; }\nvoid FixtureLatch::AcquireAgain() { if (!Acquire()) return; }\n' +CALLS = "void UseRef(FixtureLatch& s) { if (!s.Acquire()) return; }\nvoid UsePtr(FixtureLatch* s) { if (!s->Acquire()) return; }\n" + + +def names(table): + return {g["qn_prefix"] + "." + row[0] if g["qn_prefix"] else row[0] + for g in table.get("groups", []) for row in g["rows"]} + + +def run(binary): + parent = pathlib.Path.home() / ".cache" + parent.mkdir(exist_ok=True) + results = {"binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), "cases": []} + # Root include, explicit mirror, and negative controls for ambiguous visibility. + cases = [("control", False, '#include "fixture_latch.h"\n', "fixture_latch"), + ("duplicate", True, '#include "fixture_latch.h"\n', "fixture_latch"), + ("mirror", True, '#include "mirror/fixture_latch.h"\n', "mirror.fixture_latch"), + ("both", True, '#include "fixture_latch.h"\n#include "mirror/fixture_latch.h"\n', None), + ("none", True, "", None), + ("namespace", False, '#include "fixture_latch.h"\n', "fixture_latch.North"), + ("namespace_missing", False, '#include "fixture_latch.h"\n', None)] + # An upstream daemon may retain a log handle after its stdio client exits. + # Leave locked temporary files in place rather than stopping other sessions. + with tempfile.TemporaryDirectory(prefix="cpp-resolution-", dir=parent, + ignore_cleanup_errors=True) as tmp: + base = pathlib.Path(tmp) + for label, duplicate, includes, target in cases: + repo = base / label + repo.mkdir() + namespace_case = label.startswith("namespace") + if namespace_case: + header = "".join( + "namespace %s { class FixtureLatch { public: " + "bool Acquire() { return true; } void AcquireAgain() { Acquire(); } }; }\n" % ns + for ns in ("North", "South")) + impl = '#include "fixture_latch.h"\n' + calls = CALLS.replace("FixtureLatch", ("North" if target else "Absent") + "::FixtureLatch") + else: + header, impl, calls = HEADER, IMPL, CALLS + (repo / "fixture_latch.h").write_text(header, encoding="utf-8") + (repo / "fixture_latch.cpp").write_text(impl, encoding="utf-8") + (repo / "caller.cpp").write_text(includes + calls, encoding="utf-8") + if duplicate: + (repo / "mirror").mkdir() + for filename, text in [("fixture_latch.h", HEADER), ("fixture_latch.cpp", IMPL)]: + (repo / "mirror" / filename).write_text(text, encoding="utf-8") + # Cross the extraction-worker threshold in one positive and one + # ambiguity case; the small cases also cover the ordinary path. + if label in ("duplicate", "both"): + for i in range(51): + (repo / ("filler_%02d.cpp" % i)).write_text( + "int filler_%02d() { return %d; }\n" % (i, i), encoding="utf-8") + cache, runtime = base / (label + "-cache"), base / (label + "-runtime") + cache.mkdir() + runtime.mkdir() + with McpServer(str(binary), cache_dir=str(cache), + extra_env={"CBM_RUNTIME_DIR": str(runtime), + "CBM_TEST_DAEMON_RUNTIME_PARENT": str(runtime), + "CBM_MAX_THREADS": "2"}, + cwd=str(base)) as server: + initialized = server.initialize(timeout=40) + info = initialized.get("result", {}).get("serverInfo", {}) + tool_names = ({"index": "index_repository", "trace": "trace_path"} + if info.get("name") == "codebase-memory-mcp" else {}) + + def call(tool, args): + response = server.call_tool(tool_names.get(tool, tool), + dict(args, format="json"), timeout=90) + text, error = server.tool_text(response) + assert not error and not response.get("result", {}).get("isError"), text + return json.loads(text) + + indexed = call("index", {"repo_path": str(repo)}) + project = indexed["project"] + for key in ("not_indexed_files_count", "skipped_count", + "parse_partial_count", "parse_unusable_count"): + assert indexed[key] == 0, (label, key, indexed[key]) + + def trace(qn, direction): + return call("trace", {"project": project, "function_name": project + "." + qn, + "direction": direction, "depth": 1, "limit": 10}) + + observed = {} + modules = (["fixture_latch.North", "fixture_latch.South"] if namespace_case else + ["fixture_latch"] + (["mirror.fixture_latch"] if duplicate else [])) + for module in modules: + incoming = trace(module + ".FixtureLatch.Acquire", "inbound") + expected = {project + "." + module + ".FixtureLatch.AcquireAgain"} + if module == target: + expected |= {project + ".caller.UseRef", project + ".caller.UsePtr"} + actual = names(incoming.get("callers", {})) + assert actual == expected, (label, module, actual, expected) + assert incoming["callers_total"] == len(expected) + observed[module] = incoming["callers_total"] + for caller in ("UseRef", "UsePtr"): + outgoing = trace("caller." + caller, "outbound") + actual = names(outgoing.get("callees", {})) + expected = {project + "." + target + ".FixtureLatch.Acquire"} if target else set() + assert actual == expected, (label, caller, actual, expected) + results["cases"].append({"case": label, "callers": observed, + "direct_target": target, "parse_failures": 0}) + print(label + ": PASS", flush=True) + return results + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", type=pathlib.Path) + parser.add_argument("--report", type=pathlib.Path) + args = parser.parse_args() + result = run(args.binary.resolve()) + if args.report: + args.report.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(str(len(result["cases"])) + " cases passed")