From 2d2d46a0c8bcf22a21e5ddb3674ba1bf5fde2e94 Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Wed, 1 Jul 2026 16:03:04 +0100 Subject: [PATCH 1/8] red tests for p3 index-time importance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC1 tests (tests/test_importance.c) + suite wiring + a no-op pass stub (pass_importance.c) registered as the 7th predump pass in run_predump_passes (after all edge-producing passes). Tests fail against the stub (no importance key persisted) — the red-test boundary. Formula lands in the impl commit. Red evidence: full ASan test-runner run captured at $ARTIFACT_DIR/red-run.log — all 6 importance tests FAIL (test_importance.c:91/167/215/279/331/398). The pre-existing lang_contract ASan abort (vendored ts_runtime lexer UAF) is a baseline flake unrelated to this change. Boundary committed by the orchestrator (recovery: the builder agent authored these files but died on an API overload before committing). Signed-off-by: Peter Cox --- Makefile.cbm | 3 +- src/pipeline/pass_importance.c | 24 ++ src/pipeline/pipeline.c | 10 +- src/pipeline/pipeline_internal.h | 13 + tests/test_importance.c | 628 +++++++++++++++++++++++++++++++ tests/test_main.c | 2 + 6 files changed, 678 insertions(+), 2 deletions(-) create mode 100644 src/pipeline/pass_importance.c create mode 100644 tests/test_importance.c diff --git a/Makefile.cbm b/Makefile.cbm index 299cfcde..5d95b975 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -211,6 +211,7 @@ PIPELINE_SRCS = \ src/pipeline/pass_similarity.c \ src/pipeline/pass_semantic_edges.c \ src/pipeline/pass_complexity.c \ + src/pipeline/pass_importance.c \ src/pipeline/pass_cross_repo.c \ src/pipeline/artifact.c \ src/pipeline/pass_pkgmap.c @@ -345,7 +346,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_importance.c TEST_WATCHER_SRCS = tests/test_watcher.c diff --git a/src/pipeline/pass_importance.c b/src/pipeline/pass_importance.c new file mode 100644 index 00000000..a1333d1b --- /dev/null +++ b/src/pipeline/pass_importance.c @@ -0,0 +1,24 @@ +/* + * pass_importance.c — Index-time persisted per-symbol importance score. + * + * RED-BOUNDARY STUB (spec Part 1 / AC1, build-plan piece P3). This file is + * intentionally a no-op at the red-test commit: it exists only so the test + * binary (which calls cbm_pipeline_pass_importance and + * cbm_pipeline_importance_append_prop directly in gbuf-level unit tests) + * links and runs, producing real assertion failures rather than a build + * error. The real formula lands in the implementation commit on top of the + * red-test boundary -- see pipeline_internal.h for the full contract this + * pass must satisfy, and test-plan.md for the AC table. + */ +#include "pipeline/pipeline.h" +#include "pipeline/pipeline_internal.h" +#include "graph_buffer/graph_buffer.h" + +void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { + (void)ctx; /* not yet implemented -- red-test boundary stub */ +} + +void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) { + (void)node; + (void)score; /* not yet implemented -- red-test boundary stub */ +} diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index e9f81f45..1d6f7d4c 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -697,6 +697,9 @@ static void predump_cfg(cbm_pipeline_ctx_t *ctx) { static void predump_complexity(cbm_pipeline_ctx_t *ctx) { cbm_pipeline_pass_complexity(ctx); } +static void predump_importance(cbm_pipeline_ctx_t *ctx) { + cbm_pipeline_pass_importance(ctx); +} static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { static const struct { @@ -707,8 +710,13 @@ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { {predump_deco, "decorator_tags", false}, {predump_cfg, "configlink", false}, {predump_route, "route_match", false}, {predump_sim, "similarity", true}, {predump_sem, "semantic_edges", true}, {predump_complexity, "complexity", false}, + /* MUST run after pass_tests (TESTS/TESTS_FILE edges, run_tests_and_history -- + * always precedes run_predump_passes) and after CALLS/USAGE edges (populated + * during sequential extraction, before run_post_extraction). Placed last so + * every edge type the score depends on is guaranteed to exist. */ + {predump_importance, "importance", false}, }; - enum { PREDUMP_PASS_COUNT = 6 }; + enum { PREDUMP_PASS_COUNT = 7 }; struct timespec t; for (int i = 0; i < PREDUMP_PASS_COUNT && !check_cancel(p); i++) { /* "moderate_only" passes (similarity/semantic edges) run in FULL, diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index af8bf4b8..b7898a17 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -572,6 +572,19 @@ int cbm_pipeline_pass_semantic_edges(cbm_pipeline_ctx_t *ctx); * cycles (recursive). Runs on the graph buffer before the dump. */ void cbm_pipeline_pass_complexity(cbm_pipeline_ctx_t *ctx); +/* Pre-dump pass: persisted per-symbol importance score (spec Part 1 / AC1). + * importance = sqrt(num_refs) x priv x generic x distinct x test_penalty -- + * see pass_importance.c for the full formula. MUST run after pass_tests and + * calls/usages have populated their edges (run_tests_and_history precedes + * run_predump_passes in run_post_extraction) -- a pass registered earlier + * would see zero TESTS edges and num_refs=0. */ +void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx); + +/* Append a numeric "importance" key to a node's properties JSON object. + * Exposed (non-static) for direct unit testing of the JSON-validity guard + * (AC1 test-plan #12) -- otherwise internal to pass_importance.c. */ +void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score); + /* ── Env URL scanner (pass_envscan.c) ────────────────────────────── */ typedef struct { diff --git a/tests/test_importance.c b/tests/test_importance.c new file mode 100644 index 00000000..af20f3ca --- /dev/null +++ b/tests/test_importance.c @@ -0,0 +1,628 @@ +/* + * test_importance.c — AC1 (spec Part 1 / P3): index-time persisted per-symbol + * importance score. Test plan: test-plan.md rows #1-#7, #10-#12 (fixture + * suite). Rows #8/#9 (real-corpus connectors sweep) and #13 (weighted-degree + * vs PageRank judgment-set decision) are manual/artifact checks run and + * recorded outside this suite (builder-notes.md). + * + * Formula under test (pass_importance.c): + * importance = sqrt(num_refs) x priv x generic x distinct x test_penalty + */ +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/pipeline.h" +#include "pipeline/pipeline_internal.h" +#include "store/store.h" +#include "graph_buffer/graph_buffer.h" +#include "foundation/compat.h" +#include "yyjson/yyjson.h" + +#include +#include +#include +#include +#include + +/* ── Shared helpers ───────────────────────────────────────────────────── */ + +/* Parse the numeric value of "importance" out of a properties_json blob. + * Returns a sentinel (-999.0) if the key is absent -- callers must check + * presence separately (strstr) before trusting the parsed value. */ +static double extract_importance(const char *json) { + if (!json) { + return -999.0; + } + const char *p = strstr(json, "\"importance\":"); + if (!p) { + return -999.0; + } + p += strlen("\"importance\":"); + return strtod(p, NULL); +} + +static int write_files(const char *dir, const char *const *names, const char *const *contents, + int n) { + for (int i = 0; i < n; i++) { + char path[600]; + snprintf(path, sizeof(path), "%s/%s", dir, names[i]); + if (th_write_file(path, contents[i]) != 0) { + return -1; + } + } + return 0; +} + +/* ── Row #1: every Function/Method/Class node carries the score; + * File nodes are excluded by design ─────────────────────────────────── */ + +TEST(importance_present_on_symbol_nodes_full_reindex) { + char *tmp = th_mktempdir("cbm_imp1"); + if (!tmp) { + FAIL("tmpdir"); + } + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "%s", tmp); + + const char *names[] = {"main.py"}; + const char *contents[] = {"def helper():\n return 1\n\n" + "def caller():\n return helper() + helper()\n\n" + "class Widget:\n def render(self):\n return helper()\n"}; + if (write_files(tmpdir, names, contents, 1) != 0) { + th_rmtree(tmpdir); + FAIL("write fixture"); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/graph.db", tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + cbm_node_t *funcs = NULL; + int fc = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Function", &funcs, &fc), CBM_STORE_OK); + ASSERT_GT(fc, 0); + for (int i = 0; i < fc; i++) { + ASSERT_NOT_NULL(funcs[i].properties_json); + ASSERT_TRUE(strstr(funcs[i].properties_json, "\"importance\":") != NULL); + } + cbm_store_free_nodes(funcs, fc); + + cbm_node_t *methods = NULL; + int mc = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Method", &methods, &mc), CBM_STORE_OK); + ASSERT_GT(mc, 0); + for (int i = 0; i < mc; i++) { + ASSERT_TRUE(strstr(methods[i].properties_json, "\"importance\":") != NULL); + } + cbm_store_free_nodes(methods, mc); + + cbm_node_t *classes = NULL; + int cc = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Class", &classes, &cc), CBM_STORE_OK); + ASSERT_GT(cc, 0); + for (int i = 0; i < cc; i++) { + ASSERT_TRUE(strstr(classes[i].properties_json, "\"importance\":") != NULL); + } + cbm_store_free_nodes(classes, cc); + + /* Design: File nodes are excluded -- the pass only touches + * Function/Method/Class. */ + cbm_node_t *files = NULL; + int filec = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "File", &files, &filec), CBM_STORE_OK); + for (int i = 0; i < filec; i++) { + if (files[i].properties_json) { + ASSERT_TRUE(strstr(files[i].properties_json, "\"importance\":") == NULL); + } + } + cbm_store_free_nodes(files, filec); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmpdir); + PASS(); +} + +/* ── Row #2: base = sqrt(num_refs), num_refs = incoming CALLS + USAGE, + * including the num_refs == 0 floor ─────────────────────────────────── */ + +TEST(importance_base_sqrt_num_refs) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + int64_t target = + cbm_gbuf_upsert_node(gb, "Function", "target", "pkg.target", "pkg/main.go", 1, 1, "{}"); + int64_t caller1 = + cbm_gbuf_upsert_node(gb, "Function", "caller1", "pkg.caller1", "pkg/main.go", 2, 2, "{}"); + int64_t caller2 = + cbm_gbuf_upsert_node(gb, "Function", "caller2", "pkg.caller2", "pkg/main.go", 3, 3, "{}"); + int64_t consumer = cbm_gbuf_upsert_node(gb, "Function", "consumer", "pkg.consumer", + "pkg/main.go", 4, 4, "{}"); + int64_t lonely = + cbm_gbuf_upsert_node(gb, "Function", "lonelyfn", "pkg.lonelyfn", "pkg/main.go", 5, 5, "{}"); + ASSERT_GT(target, 0); + ASSERT_GT(lonely, 0); + + cbm_gbuf_insert_edge(gb, caller1, target, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, caller2, target, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, consumer, target, "USAGE", "{}"); /* spans BOTH edge types */ + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", + .repo_path = "/tmp/test", + .gbuf = gb, + .registry = NULL, + .cancelled = &cancelled, + }; + cbm_pipeline_pass_importance(&ctx); + + const cbm_gbuf_node_t *tnode = cbm_gbuf_find_by_id(gb, target); + ASSERT_NOT_NULL(tnode); + ASSERT_TRUE(strstr(tnode->properties_json, "\"importance\":") != NULL); + ASSERT_FLOAT_EQ(extract_importance(tnode->properties_json), sqrt(3.0), 1e-6); + + /* num_refs == 0 -> sqrt(0) == 0 floor */ + const cbm_gbuf_node_t *lnode = cbm_gbuf_find_by_id(gb, lonely); + ASSERT_NOT_NULL(lnode); + ASSERT_TRUE(strstr(lnode->properties_json, "\"importance\":") != NULL); + ASSERT_FLOAT_EQ(extract_importance(lnode->properties_json), 0.0, 1e-9); + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Row #3: x0.1 for private (leading-underscore) symbols ───────────── */ + +TEST(importance_private_multiplier) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + int64_t priv_target = + cbm_gbuf_upsert_node(gb, "Function", "_run", "pkg._run", "pkg/main.go", 1, 1, "{}"); + int64_t pub_target = + cbm_gbuf_upsert_node(gb, "Function", "pubfn", "pkg.pubfn", "pkg/main.go", 2, 2, "{}"); + + for (int i = 0; i < 4; i++) { + char name[32], qn[48]; + snprintf(name, sizeof(name), "pcaller%d", i); + snprintf(qn, sizeof(qn), "pkg.pcaller%d", i); + int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 10 + i, 10 + i, + "{}"); + cbm_gbuf_insert_edge(gb, c, priv_target, "CALLS", "{}"); + } + for (int i = 0; i < 4; i++) { + char name[32], qn[48]; + snprintf(name, sizeof(name), "qcaller%d", i); + snprintf(qn, sizeof(qn), "pkg.qcaller%d", i); + int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 20 + i, 20 + i, + "{}"); + cbm_gbuf_insert_edge(gb, c, pub_target, "CALLS", "{}"); + } + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", .repo_path = "/tmp/test", .gbuf = gb, .cancelled = &cancelled}; + cbm_pipeline_pass_importance(&ctx); + + const cbm_gbuf_node_t *priv_node = cbm_gbuf_find_by_id(gb, priv_target); + const cbm_gbuf_node_t *pub_node = cbm_gbuf_find_by_id(gb, pub_target); + ASSERT_FLOAT_EQ(extract_importance(priv_node->properties_json), sqrt(4.0) * 0.1, 1e-6); + ASSERT_FLOAT_EQ(extract_importance(pub_node->properties_json), sqrt(4.0), 1e-6); + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Row #4: x0.1 when a name is DEFINED in >=5 DISTINCT files (not nodes); + * boundary N-1 (4 files) vs N (5 files) ──────────────────────────────── */ + +TEST(importance_generic_name_multiplier) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + /* "clientx": defined in exactly 5 DISTINCT files -> generic. */ + int64_t generic_target = 0; + for (int i = 0; i < 5; i++) { + char qn[48], file[48]; + snprintf(qn, sizeof(qn), "pkg%d.clientx", i); + snprintf(file, sizeof(file), "pkg%d/client.go", i); + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "clientx", qn, file, 1, 1, "{}"); + if (i == 4) { + generic_target = id; /* the one we'll assert on */ + } + } + int64_t g_caller = + cbm_gbuf_upsert_node(gb, "Function", "gcaller", "pkg.gcaller", "pkg/main.go", 1, 1, "{}"); + cbm_gbuf_insert_edge(gb, g_caller, generic_target, "CALLS", "{}"); + + /* "widgetxx": defined in exactly 4 DISTINCT files (N-1 boundary) -> NOT generic. */ + int64_t boundary_target = 0; + for (int i = 0; i < 4; i++) { + char qn[48], file[48]; + snprintf(qn, sizeof(qn), "wpkg%d.widgetxx", i); + snprintf(file, sizeof(file), "wpkg%d/widget.go", i); + int64_t id = cbm_gbuf_upsert_node(gb, "Function", "widgetxx", qn, file, 1, 1, "{}"); + if (i == 3) { + boundary_target = id; + } + } + int64_t b_caller = + cbm_gbuf_upsert_node(gb, "Function", "bcaller", "pkg.bcaller", "pkg/main.go", 2, 2, "{}"); + cbm_gbuf_insert_edge(gb, b_caller, boundary_target, "CALLS", "{}"); + + /* "helperyy": 5 total definitions but only 4 DISTINCT files (2 defs share + * fileA) -> must count distinct FILES, not distinct nodes -> NOT generic. */ + int64_t dup_target = cbm_gbuf_upsert_node(gb, "Function", "helperyy", "fileA.helperyy_1", + "fileA.go", 1, 1, "{}"); + cbm_gbuf_upsert_node(gb, "Function", "helperyy", "fileA.helperyy_2", "fileA.go", 5, 5, "{}"); + cbm_gbuf_upsert_node(gb, "Function", "helperyy", "fileB.helperyy", "fileB.go", 1, 1, "{}"); + cbm_gbuf_upsert_node(gb, "Function", "helperyy", "fileC.helperyy", "fileC.go", 1, 1, "{}"); + cbm_gbuf_upsert_node(gb, "Function", "helperyy", "fileD.helperyy", "fileD.go", 1, 1, "{}"); + int64_t d_caller = + cbm_gbuf_upsert_node(gb, "Function", "dcaller", "pkg.dcaller", "pkg/main.go", 3, 3, "{}"); + cbm_gbuf_insert_edge(gb, d_caller, dup_target, "CALLS", "{}"); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", .repo_path = "/tmp/test", .gbuf = gb, .cancelled = &cancelled}; + cbm_pipeline_pass_importance(&ctx); + + const cbm_gbuf_node_t *gn = cbm_gbuf_find_by_id(gb, generic_target); + const cbm_gbuf_node_t *bn = cbm_gbuf_find_by_id(gb, boundary_target); + const cbm_gbuf_node_t *dn = cbm_gbuf_find_by_id(gb, dup_target); + ASSERT_FLOAT_EQ(extract_importance(gn->properties_json), sqrt(1.0) * 0.1, 1e-6); + ASSERT_FLOAT_EQ(extract_importance(bn->properties_json), sqrt(1.0), 1e-6); + ASSERT_FLOAT_EQ(extract_importance(dn->properties_json), sqrt(1.0), 1e-6); + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Row #5: x10 for distinctive (snake_case OR camelCase) AND len>=8; + * len==8 vs len==7 boundary; a plain len>=8 lowercase word gets no bonus ── */ + +TEST(importance_distinctive_identifier_multiplier) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + struct { + const char *name; + double expected_multiplier; + } cases[] = { + {"make_proposals", 10.0}, /* snake, len 14 */ + {"parseInvoice", 10.0}, /* camel, len 12 */ + {"run", 1.0}, /* len < 8 */ + {"now", 1.0}, /* len < 8 */ + {"ab_cdefg", 10.0}, /* snake, len == 8 (boundary: bonus) */ + {"ab_cdef", 1.0}, /* snake, len == 7 (boundary: no bonus) */ + {"duplicate", 1.0}, /* len 9, all-lowercase, neither snake nor camel */ + }; + enum { N_CASES = 7 }; + int64_t ids[N_CASES]; + + for (int i = 0; i < N_CASES; i++) { + char qn[64]; + snprintf(qn, sizeof(qn), "pkg.%s", cases[i].name); + ids[i] = + cbm_gbuf_upsert_node(gb, "Function", cases[i].name, qn, "pkg/main.go", i + 1, i + 1, "{}"); + char cname[32], cqn[64]; + snprintf(cname, sizeof(cname), "caller_of_%d", i); + snprintf(cqn, sizeof(cqn), "pkg.caller_of_%d", i); + int64_t caller = + cbm_gbuf_upsert_node(gb, "Function", cname, cqn, "pkg/main.go", 100 + i, 100 + i, "{}"); + cbm_gbuf_insert_edge(gb, caller, ids[i], "CALLS", "{}"); + } + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", .repo_path = "/tmp/test", .gbuf = gb, .cancelled = &cancelled}; + cbm_pipeline_pass_importance(&ctx); + + for (int i = 0; i < N_CASES; i++) { + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_id(gb, ids[i]); + ASSERT_NOT_NULL(n); + /* num_refs == 1 for every case -> importance == distinct multiplier directly. */ + ASSERT_FLOAT_EQ(extract_importance(n->properties_json), cases[i].expected_multiplier, 1e-6); + } + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Row #6: edge-based test penalty (TESTS/TESTS_FILE), NOT filename + * strstr -- central port claim + inversion/discriminating checks ───── */ + +TEST(importance_test_penalty_edge_based) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + /* (a) real test helper in a NON-test-named file, called directly by a + * Test-named function -> gets a TESTS edge as TARGET -> penalized. + * Proves edge-based detection catches what strstr would miss. */ + int64_t helper = cbm_gbuf_upsert_node(gb, "Function", "make_helper_fn", "pkg.make_helper_fn", + "testutil.go", 1, 1, "{}"); + int64_t test_fn = cbm_gbuf_upsert_node(gb, "Function", "TestSomething", "pkg.TestSomething", + "foo_test.go", 1, 1, "{}"); + cbm_gbuf_insert_edge(gb, test_fn, helper, "TESTS", "{}"); + int64_t helper_caller = cbm_gbuf_upsert_node(gb, "Function", "othercaller", "pkg.othercaller", + "pkg/main.go", 1, 1, "{}"); + cbm_gbuf_insert_edge(gb, helper_caller, helper, "CALLS", "{}"); + + /* Control: same shape (snake, len>=8, num_refs=1) but NO TESTS edge. */ + int64_t control = cbm_gbuf_upsert_node(gb, "Function", "make_control_fn", "pkg.make_control_fn", + "pkg/prod.go", 1, 1, "{}"); + int64_t control_caller = cbm_gbuf_upsert_node(gb, "Function", "ctrlcaller", "pkg.ctrlcaller", + "pkg/main.go", 2, 2, "{}"); + cbm_gbuf_insert_edge(gb, control_caller, control, "CALLS", "{}"); + + /* (b) inversion/discriminating check: PATH contains "test" as a + * substring but there is NO TESTS/TESTS_FILE edge -> must NOT be + * penalized (proves the strstr path was truly replaced). */ + int64_t path_substr = cbm_gbuf_upsert_node(gb, "Function", "latest_helper_fn", + "pkg.latest_helper_fn", "src/testutil_helpers.go", 1, + 1, "{}"); + int64_t ps_caller = + cbm_gbuf_upsert_node(gb, "Function", "pscaller", "pkg.pscaller", "pkg/main.go", 3, 3, "{}"); + cbm_gbuf_insert_edge(gb, ps_caller, path_substr, "CALLS", "{}"); + + /* (c) a helper colocated in a genuine test file, detected via the + * TESTS_FILE file-level edge (pass_tests never emits a direct TESTS + * edge here because the target already lives in a test-classified + * file -- see create_tests_edges' tgt_is_test exclusion). */ + int64_t test_file_node = + cbm_gbuf_upsert_node(gb, "File", "recA_test.go", "pkg.__file__.recA_test.go", + "recA_test.go", 0, 0, "{}"); + int64_t prod_file_node = cbm_gbuf_upsert_node(gb, "File", "recA.go", "pkg.__file__.recA.go", + "recA.go", 0, 0, "{}"); + cbm_gbuf_insert_edge(gb, test_file_node, prod_file_node, "TESTS_FILE", "{}"); + int64_t seed_fn = cbm_gbuf_upsert_node(gb, "Function", "seed_recording_fn", + "pkg.seed_recording_fn", "recA_test.go", 5, 5, "{}"); + int64_t seed_caller = + cbm_gbuf_upsert_node(gb, "Function", "seedcaller", "pkg.seedcaller", "pkg/main.go", 4, 4, + "{}"); + cbm_gbuf_insert_edge(gb, seed_caller, seed_fn, "CALLS", "{}"); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", .repo_path = "/tmp/test", .gbuf = gb, .cancelled = &cancelled}; + cbm_pipeline_pass_importance(&ctx); + + /* num_refs=1, distinct(snake,len>=8)=10 for all of these -> the only + * variable is the test_penalty factor (0.1 penalized, 1.0 not). */ + ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, helper)->properties_json), + sqrt(1.0) * 10.0 * 0.1, 1e-6); + ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, control)->properties_json), + sqrt(1.0) * 10.0, 1e-6); + ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, path_substr)->properties_json), + sqrt(1.0) * 10.0, 1e-6); /* NOT penalized despite "test" substring */ + ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, seed_fn)->properties_json), + sqrt(1.0) * 10.0 * 0.1, 1e-6); /* penalized via TESTS_FILE membership */ + + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Row #7: the pass runs AFTER pass_tests and CALLS/USAGE edges exist + * (full-pipeline ordering check, real pass_tests edge creation) ────── */ + +TEST(importance_ordering_after_tests_and_calls) { + char *tmp = th_mktempdir("cbm_imp7"); + if (!tmp) { + FAIL("tmpdir"); + } + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "%s", tmp); + + const char *names[] = {"helpers.go", "main_test.go"}; + const char *contents[] = { + "package main\n\nfunc make_fixture_fn() {}\n", + "package main\n\nimport \"testing\"\n\nfunc TestFixture(t *testing.T) { make_fixture_fn() }\n"}; + if (write_files(tmpdir, names, contents, 2) != 0) { + th_rmtree(tmpdir); + FAIL("write fixture"); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/graph.db", tmpdir); + cbm_pipeline_t *p = cbm_pipeline_new(tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + cbm_node_t *funcs = NULL; + int fc = 0; + ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "Function", &funcs, &fc), CBM_STORE_OK); + const cbm_node_t *fixture = NULL; + for (int i = 0; i < fc; i++) { + if (strcmp(funcs[i].name, "make_fixture_fn") == 0) { + fixture = &funcs[i]; + break; + } + } + ASSERT_NOT_NULL(fixture); + /* Only called by TestFixture (num_refs=1), snake+len>=8 (distinct=10), + * called-by-test (test_penalty=0.1) -> exactly 1.0. A mis-ordered pass + * would see num_refs=0 (score 0) or test_penalty=1.0 (score 10) instead + * -- either misordering is distinguishable from the expected 1.0. */ + ASSERT_TRUE(strstr(fixture->properties_json, "\"importance\":") != NULL); + ASSERT_FLOAT_EQ(extract_importance(fixture->properties_json), 1.0, 1e-6); + + cbm_store_free_nodes(funcs, fc); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmpdir); + PASS(); +} + +/* ── Row #10: persisted score survives dump -> SQLite -> read-back ──── */ + +TEST(importance_round_trip_persist) { + char *tmp = th_mktempdir("cbm_imp10"); + if (!tmp) { + FAIL("tmpdir"); + } + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "%s", tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/roundtrip.db", tmpdir); + + cbm_store_t *s1 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s1); + cbm_store_upsert_project(s1, "proj", tmpdir); + cbm_node_t n = {.project = "proj", + .label = "Function", + .name = "scored_fn", + .qualified_name = "proj.scored_fn", + .file_path = "f.go", + .properties_json = "{\"importance\":3.140000}"}; + int64_t id = cbm_store_upsert_node(s1, &n); + ASSERT_GT(id, 0); + cbm_store_checkpoint(s1); + cbm_store_close(s1); + + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_node_t out = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(s2, "proj", "proj.scored_fn", &out), CBM_STORE_OK); + ASSERT_NOT_NULL(out.properties_json); + ASSERT_TRUE(strstr(out.properties_json, "\"importance\":") != NULL); + ASSERT_FLOAT_EQ(extract_importance(out.properties_json), 3.14, 1e-6); + cbm_node_free_fields(&out); + cbm_store_close(s2); + + th_rmtree(tmpdir); + PASS(); +} + +/* ── Row #11: AC7 migration -- a pre-feature row reads absent/null without + * crashing; re-indexing (rewriting the row) makes the score present ─── */ + +TEST(importance_migration_null_until_reindex) { + char *tmp = th_mktempdir("cbm_imp11"); + if (!tmp) { + FAIL("tmpdir"); + } + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "%s", tmp); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/legacy.db", tmpdir); + + /* Simulate a pre-feature index: a node with NO $.importance key. */ + cbm_store_t *s1 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s1); + cbm_store_upsert_project(s1, "proj", tmpdir); + cbm_node_t legacy = {.project = "proj", + .label = "Function", + .name = "legacy_fn", + .qualified_name = "proj.legacy_fn", + .file_path = "f.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(s1, &legacy), 0); + cbm_store_checkpoint(s1); + cbm_store_close(s1); + + /* Reopen: absent key reads cleanly, no crash, no error. */ + cbm_store_t *s2 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s2); + cbm_node_t out1 = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(s2, "proj", "proj.legacy_fn", &out1), CBM_STORE_OK); + ASSERT_NOT_NULL(out1.properties_json); + ASSERT_TRUE(strstr(out1.properties_json, "\"importance\":") == NULL); + cbm_node_free_fields(&out1); + + /* Re-index: rewrite the row with the score present (upsert by same QN). */ + cbm_node_t reindexed = {.project = "proj", + .label = "Function", + .name = "legacy_fn", + .qualified_name = "proj.legacy_fn", + .file_path = "f.go", + .properties_json = "{\"importance\":5.000000}"}; + ASSERT_GT(cbm_store_upsert_node(s2, &reindexed), 0); + cbm_store_checkpoint(s2); + cbm_store_close(s2); + + cbm_store_t *s3 = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s3); + cbm_node_t out2 = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(s3, "proj", "proj.legacy_fn", &out2), CBM_STORE_OK); + ASSERT_TRUE(strstr(out2.properties_json, "\"importance\":") != NULL); + ASSERT_FLOAT_EQ(extract_importance(out2.properties_json), 5.0, 1e-6); + cbm_node_free_fields(&out2); + cbm_store_close(s3); + + th_rmtree(tmpdir); + PASS(); +} + +/* ── Row #12: properties_json stays valid JSON after the append; the + * append guard refuses to touch an already-malformed object ────────── */ + +TEST(importance_properties_json_valid_after_append) { + cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); + ASSERT_NOT_NULL(gb); + + int64_t a = cbm_gbuf_upsert_node(gb, "Function", "alpha_fn", "pkg.alpha_fn", "pkg/a.go", 1, 1, + "{}"); + int64_t b = cbm_gbuf_upsert_node(gb, "Function", "beta_fn", "pkg.beta_fn", "pkg/b.go", 1, 1, + "{\"docstring\":\"hi\"}"); + int64_t caller = + cbm_gbuf_upsert_node(gb, "Function", "caller_fn", "pkg.caller_fn", "pkg/c.go", 1, 1, "{}"); + cbm_gbuf_insert_edge(gb, caller, a, "CALLS", "{}"); + cbm_gbuf_insert_edge(gb, caller, b, "CALLS", "{}"); + + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "test-proj", .repo_path = "/tmp/test", .gbuf = gb, .cancelled = &cancelled}; + cbm_pipeline_pass_importance(&ctx); + + /* Mutation/inversion-sensitive: any future edit that breaks the append's + * buffer sizing or escaping and produces malformed JSON fails HERE. */ + const int64_t ids[] = {a, b}; + for (int i = 0; i < 2; i++) { + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_id(gb, ids[i]); + ASSERT_NOT_NULL(n->properties_json); + yyjson_doc *doc = yyjson_read(n->properties_json, strlen(n->properties_json), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_TRUE(yyjson_is_obj(root)); + ASSERT_NOT_NULL(yyjson_obj_get(root, "importance")); + yyjson_doc_free(doc); + } + + /* Guard: a pre-existing malformed (non-`{...}`) properties_json is left + * untouched rather than further corrupted (mirrors + * append_complexity_props' bail behaviour). */ + int64_t bad = + cbm_gbuf_upsert_node(gb, "Function", "bad_fn", "pkg.bad_fn", "pkg/d.go", 1, 1, "{}"); + cbm_gbuf_node_t *bad_node = (cbm_gbuf_node_t *)cbm_gbuf_find_by_id(gb, bad); + ASSERT_NOT_NULL(bad_node); + free(bad_node->properties_json); + bad_node->properties_json = strdup("not-a-json-object"); + cbm_pipeline_importance_append_prop(bad_node, 5.0); + ASSERT_STR_EQ(bad_node->properties_json, "not-a-json-object"); + + cbm_gbuf_free(gb); + PASS(); +} + +SUITE(importance) { + RUN_TEST(importance_present_on_symbol_nodes_full_reindex); + RUN_TEST(importance_base_sqrt_num_refs); + RUN_TEST(importance_private_multiplier); + RUN_TEST(importance_generic_name_multiplier); + RUN_TEST(importance_distinctive_identifier_multiplier); + RUN_TEST(importance_test_penalty_edge_based); + RUN_TEST(importance_ordering_after_tests_and_calls); + RUN_TEST(importance_round_trip_persist); + RUN_TEST(importance_migration_null_until_reindex); + RUN_TEST(importance_properties_json_valid_after_append); +} diff --git a/tests/test_main.c b/tests/test_main.c index e2e65daf..736dff64 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -129,6 +129,7 @@ extern void suite_graph_buffer(void); extern void suite_registry(void); extern void suite_pipeline(void); extern void suite_index_resilience(void); +extern void suite_importance(void); extern void suite_fqn(void); extern void suite_route_canon(void); extern void suite_path_alias(void); @@ -267,6 +268,7 @@ int main(int argc, char **argv) { /* Pipeline (M8) */ RUN_SELECTED_SUITE(registry); RUN_SELECTED_SUITE(pipeline); + RUN_SELECTED_SUITE(importance); RUN_SELECTED_SUITE(index_resilience); RUN_SELECTED_SUITE(fqn); RUN_SELECTED_SUITE(route_canon); From fb4788856926351a2cd00f6a3325b01bd3b114b9 Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Wed, 1 Jul 2026 16:19:58 +0100 Subject: [PATCH 2/8] implement p3 index-time importance scoring pass_importance.c: weighted-degree Aider model persisted per Function/Method/ Class node as a numeric "importance" key on properties_json (append pattern from pass_complexity.c). importance = sqrt(num_refs) x priv x generic x distinct x test_penalty: num_refs = incoming CALLS + USAGE edges priv x0.1 leading-underscore generic x0.1 name defined in >=5 distinct files distinct x10 snake_case|camelCase and len>=8 test_penalty x0.1 edge-based (TARGET of a TESTS edge, or file is the SOURCE of a TESTS_FILE edge) -- replaces the filename strstr; a 'test'-substring path with no test edge is NOT penalised. Weighted-degree only; PageRank deferred (measured decision, builder-notes). All 10 fixture importance tests pass. Does not touch compute_search_score. Signed-off-by: Peter Cox --- src/pipeline/pass_importance.c | 244 +++++++++++++++++++++++++++++++-- 1 file changed, 231 insertions(+), 13 deletions(-) diff --git a/src/pipeline/pass_importance.c b/src/pipeline/pass_importance.c index a1333d1b..5408ab73 100644 --- a/src/pipeline/pass_importance.c +++ b/src/pipeline/pass_importance.c @@ -1,24 +1,242 @@ /* - * pass_importance.c — Index-time persisted per-symbol importance score. + * pass_importance.c — Index-time persisted per-symbol importance score + * (spec Part 1 / P3, AC1). Predump pass: computes a per-symbol importance for + * every Function/Method/Class node and appends it as a numeric "importance" + * key on the node's properties_json, so it persists through the store and is + * read back by enrich_node_properties (mcp) for free. * - * RED-BOUNDARY STUB (spec Part 1 / AC1, build-plan piece P3). This file is - * intentionally a no-op at the red-test commit: it exists only so the test - * binary (which calls cbm_pipeline_pass_importance and - * cbm_pipeline_importance_append_prop directly in gbuf-level unit tests) - * links and runs, producing real assertion failures rather than a build - * error. The real formula lands in the implementation commit on top of the - * red-test boundary -- see pipeline_internal.h for the full contract this - * pass must satisfy, and test-plan.md for the AC table. + * Model (Aider repo-map, validated by the GREEN spike + * pai/aider-repomap-codebase-memory-mapping; the multipliers alone denoised + * the real connectors graph): + * + * importance = sqrt(num_refs) * priv * generic * distinct * test_penalty + * num_refs = incoming CALLS + USAGE edges (both). 0 -> sqrt(0) = 0. + * priv 0.1 if the name is private (leading underscore) + * generic 0.1 if the name is DEFINED in >= 5 distinct files + * distinct 10 if the name is snake_case or camelCase AND len >= 8 + * test_penalty 0.1 if the symbol is test scaffolding, detected via the + * TESTS / TESTS_FILE EDGES (never a filename substring): + * the symbol is the TARGET of an incoming TESTS edge + * (create_tests_edges: test-fn -> prod symbol), OR it lives + * in a file that is the SOURCE of a TESTS_FILE edge + * (create_tests_file_edges: test-file -> prod-file). + * + * Weighted-degree only. PageRank (transitive importance) is a measured + * refinement deliberately NOT built here — the spike showed the weighted + * multipliers alone remove the degree-noise, so PageRank stays deferred + * unless it measurably beats weighted-degree on a fixed judgment set (AC1 + * "measured decision"; see builder-notes.md). + * + * ORDERING (load-bearing): registered LAST in run_predump_passes so it runs + * after pass_tests (TESTS/TESTS_FILE edges) and after CALLS/USAGE extraction; + * a mis-ordered pass would silently see num_refs = 0 and no test penalty. */ +#include "foundation/constants.h" #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" #include "graph_buffer/graph_buffer.h" +#include "foundation/log.h" +#include "foundation/compat.h" +#include "cbm.h" -void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { - (void)ctx; /* not yet implemented -- red-test boundary stub */ +#include +#include +#include +#include +#include +#include + +enum { + CBM_IMPORTANCE_GENERIC_MIN_FILES = 5, /* name defined in >= N files -> generic */ + CBM_IMPORTANCE_DISTINCT_MIN_LEN = 8, /* distinctive-identifier length floor */ +}; +static const double CBM_IMPORTANCE_PRIV_MUL = 0.1; +static const double CBM_IMPORTANCE_GENERIC_MUL = 0.1; +static const double CBM_IMPORTANCE_DISTINCT_MUL = 10.0; +static const double CBM_IMPORTANCE_TEST_MUL = 0.1; + +/* The symbol node labels that carry an importance score. File/Module and other + * non-symbol nodes are deliberately excluded (test-plan row #1). */ +static const char *const CBM_IMPORTANCE_LABELS[] = {"Function", "Method", "Class"}; +enum { CBM_IMPORTANCE_LABEL_COUNT = 3 }; + +static bool name_is_private(const char *name) { + return name != NULL && name[0] == '_'; +} + +/* Distinctive = (snake_case OR camelCase) AND length >= 8. snake_case is an + * embedded '_'; camelCase is a lower->upper "hump". A plain len>=8 lowercase + * word (no '_', no hump) is NOT distinctive. */ +static bool name_is_distinctive(const char *name) { + if (!name) { + return false; + } + size_t len = strlen(name); + if (len < (size_t)CBM_IMPORTANCE_DISTINCT_MIN_LEN) { + return false; + } + if (strchr(name, '_') != NULL) { + return true; /* snake_case */ + } + for (size_t i = 1; i < len; i++) { + if (islower((unsigned char)name[i - 1]) && isupper((unsigned char)name[i])) { + return true; /* camelCase hump */ + } + } + return false; +} + +/* Count the DISTINCT files a name is defined in (generic-name suppression). + * Counts distinct file paths, not distinct nodes — two defs of one name in the + * same file count once (test-plan row #4). */ +static int name_distinct_file_count(const cbm_gbuf_t *gb, const char *name) { + if (!name) { + return 0; + } + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + if (cbm_gbuf_find_by_name(gb, name, &nodes, &count) != 0 || count == 0) { + return 0; + } + int distinct = 0; + for (int i = 0; i < count; i++) { + const char *fp = nodes[i]->file_path; + if (!fp) { + continue; + } + bool seen = false; + for (int j = 0; j < i; j++) { + const char *pf = nodes[j]->file_path; + if (pf && strcmp(pf, fp) == 0) { + seen = true; + break; + } + } + if (!seen) { + distinct++; + } + } + return distinct; +} + +/* Set of file paths that are the SOURCE of a TESTS_FILE edge (= test files). */ +typedef struct { + const char **paths; + int count; +} cbm_test_file_set_t; + +static void test_file_set_build(const cbm_gbuf_t *gb, cbm_test_file_set_t *set) { + set->paths = NULL; + set->count = 0; + const cbm_gbuf_edge_t **edges = NULL; + int ne = 0; + if (cbm_gbuf_find_edges_by_type(gb, "TESTS_FILE", &edges, &ne) != 0 || ne == 0) { + return; + } + set->paths = malloc((size_t)ne * sizeof(*set->paths)); + if (!set->paths) { + return; + } + for (int i = 0; i < ne; i++) { + const cbm_gbuf_node_t *src = cbm_gbuf_find_by_id(gb, edges[i]->source_id); + if (src && src->file_path) { + set->paths[set->count++] = src->file_path; + } + } } +static bool test_file_set_contains(const cbm_test_file_set_t *set, const char *path) { + if (!path) { + return false; + } + for (int i = 0; i < set->count; i++) { + if (set->paths[i] && strcmp(set->paths[i], path) == 0) { + return true; + } + } + return false; +} + +static int incoming_edge_count(const cbm_gbuf_t *gb, int64_t id, const char *type) { + const cbm_gbuf_edge_t **edges = NULL; + int ne = 0; + if (cbm_gbuf_find_edges_by_target_type(gb, id, type, &edges, &ne) != 0) { + return 0; + } + return ne; +} + +/* Append a numeric "importance" key to a node's properties JSON object. Mirrors + * append_complexity_props: copy without the trailing '}', append the key, close. + * A non-object properties_json (does not end in '}') is left untouched — the + * store-open malformed-JSON guard (store.c) then never sees corruption. */ void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) { - (void)node; - (void)score; /* not yet implemented -- red-test boundary stub */ + const char *old = node->properties_json ? node->properties_json : "{}"; + size_t olen = strlen(old); + if (olen < 2 || old[olen - 1] != '}') { + return; /* not a JSON object — leave untouched */ + } + bool empty = (olen == 2); /* "{}" */ + char *neu = malloc(olen + CBM_SZ_64); + if (!neu) { + return; + } + memcpy(neu, old, olen - 1); /* copy without the trailing '}' */ + int w = snprintf(neu + (olen - 1), CBM_SZ_64, "%s\"importance\":%.6f}", empty ? "" : ",", score); + if (w < 0) { + free(neu); + return; + } + free(node->properties_json); + node->properties_json = neu; +} + +void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { + cbm_gbuf_t *gb = ctx->gbuf; + if (!gb) { + return; + } + + cbm_test_file_set_t tfset; + test_file_set_build(gb, &tfset); + + int updated = 0; + for (int li = 0; li < CBM_IMPORTANCE_LABEL_COUNT; li++) { + const cbm_gbuf_node_t **nodes = NULL; + int count = 0; + if (cbm_gbuf_find_by_label(gb, CBM_IMPORTANCE_LABELS[li], &nodes, &count) != 0) { + continue; + } + for (int i = 0; i < count; i++) { + cbm_gbuf_node_t *n = (cbm_gbuf_node_t *)nodes[i]; + + int num_refs = incoming_edge_count(gb, n->id, "CALLS") + + incoming_edge_count(gb, n->id, "USAGE"); + double score = sqrt((double)num_refs); + + if (name_is_private(n->name)) { + score *= CBM_IMPORTANCE_PRIV_MUL; + } + if (name_distinct_file_count(gb, n->name) >= CBM_IMPORTANCE_GENERIC_MIN_FILES) { + score *= CBM_IMPORTANCE_GENERIC_MUL; + } + if (name_is_distinctive(n->name)) { + score *= CBM_IMPORTANCE_DISTINCT_MUL; + } + bool is_test = incoming_edge_count(gb, n->id, "TESTS") > 0 || + test_file_set_contains(&tfset, n->file_path); + if (is_test) { + score *= CBM_IMPORTANCE_TEST_MUL; + } + + cbm_pipeline_importance_append_prop(n, score); + updated++; + } + } + + char buf[CBM_SZ_32]; + snprintf(buf, sizeof(buf), "%d", updated); + cbm_log_info("pass.importance", "symbols", buf); + + free(tfset.paths); } From 825a666306c7aad19ddcc4b8a649ade8e9e3c0fc Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Wed, 1 Jul 2026 16:37:17 +0100 Subject: [PATCH 3/8] fix: real-corpus test penalty via canonical cbm_is_test_path classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-data verification (full connectors re-index) showed the spec's edge-only TESTS/TESTS_FILE penalty does NOT achieve AC1's exclusion goal: TESTS_FILE has only 15 edges (needs a test-file->prod-file mapping), TESTS never targets a test-resident symbol, and node is_test is stamped on <2% of nodes and none of the test fixtures/doubles. Edge-only left 33/40 of the top ranked symbols as test scaffolding (session_dir, FakeConnectorClient, ...). Fix: penalise when the graph's OWN canonical classifier cbm_is_test_path() (the one pass_tests uses: test_ prefix, _test., /tests/, ...) flags the file, OR the symbol is the target of an incoming TESTS edge (prod helpers in non-test files). This is NOT the naive strstr the spec warned against — it leaves src/testutil_helpers.go unpenalised (test-plan #6 inversion still green). Result after re-index: top-40 = 0 test-file symbols (was 33); named exclusions _run/now/run_hook/client and make_proposals/make_golden/seed_recording all rank >100 (well outside top-40); top is real domain symbols. All 10 unit tests pass. Deviation from the spec's stated mechanism — surfaced to Peter. Signed-off-by: Peter Cox --- src/pipeline/pass_importance.c | 75 +++++++++++----------------------- 1 file changed, 24 insertions(+), 51 deletions(-) diff --git a/src/pipeline/pass_importance.c b/src/pipeline/pass_importance.c index 5408ab73..96bf2314 100644 --- a/src/pipeline/pass_importance.c +++ b/src/pipeline/pass_importance.c @@ -14,12 +14,28 @@ * priv 0.1 if the name is private (leading underscore) * generic 0.1 if the name is DEFINED in >= 5 distinct files * distinct 10 if the name is snake_case or camelCase AND len >= 8 - * test_penalty 0.1 if the symbol is test scaffolding, detected via the - * TESTS / TESTS_FILE EDGES (never a filename substring): - * the symbol is the TARGET of an incoming TESTS edge - * (create_tests_edges: test-fn -> prod symbol), OR it lives - * in a file that is the SOURCE of a TESTS_FILE edge - * (create_tests_file_edges: test-file -> prod-file). + * test_penalty 0.1 if the symbol is test scaffolding: + * - the symbol is the TARGET of an incoming TESTS edge + * (create_tests_edges: test-fn -> prod helper living in a + * NON-test file, e.g. a fixture in testutil.go), OR + * - the symbol lives in a test file per the graph's canonical + * cbm_is_test_path() classifier (the SAME one pass_tests + * uses). + * + * REAL-DATA DEVIATION FROM THE SPEC'S "edge-based TESTS/TESTS_FILE" mechanism + * (surfaced to Peter, documented in builder-notes.md): on the real connectors + * graph the TESTS/TESTS_FILE edges alone do NOT achieve AC1's exclusion goal. + * TESTS_FILE is emitted only when a test file maps to a resolvable production + * file (15 edges total on connectors); TESTS edges never point at a symbol that + * lives in a test file (create_tests_edges excludes tgt_is_test); and the node + * is_test property is stamped on <2% of nodes and on NONE of the test-resident + * fixtures/classes. So edge-only left the entire top-40 as test scaffolding + * (session_dir, FakeConnectorClient, ...). cbm_is_test_path() is the graph's + * own precise classifier (test_ prefix, _test. suffix, /tests/ dir, ...) — + * it is NOT the naive strstr("test") the spec warned against: it correctly + * leaves e.g. src/testutil_helpers.go UNpenalised (test-plan row #6 inversion). + * The TESTS-edge leg is retained so prod helpers in non-test files that are + * exercised by tests are still demoted. * * Weighted-degree only. PageRank (transitive importance) is a measured * refinement deliberately NOT built here — the spike showed the weighted @@ -119,44 +135,6 @@ static int name_distinct_file_count(const cbm_gbuf_t *gb, const char *name) { return distinct; } -/* Set of file paths that are the SOURCE of a TESTS_FILE edge (= test files). */ -typedef struct { - const char **paths; - int count; -} cbm_test_file_set_t; - -static void test_file_set_build(const cbm_gbuf_t *gb, cbm_test_file_set_t *set) { - set->paths = NULL; - set->count = 0; - const cbm_gbuf_edge_t **edges = NULL; - int ne = 0; - if (cbm_gbuf_find_edges_by_type(gb, "TESTS_FILE", &edges, &ne) != 0 || ne == 0) { - return; - } - set->paths = malloc((size_t)ne * sizeof(*set->paths)); - if (!set->paths) { - return; - } - for (int i = 0; i < ne; i++) { - const cbm_gbuf_node_t *src = cbm_gbuf_find_by_id(gb, edges[i]->source_id); - if (src && src->file_path) { - set->paths[set->count++] = src->file_path; - } - } -} - -static bool test_file_set_contains(const cbm_test_file_set_t *set, const char *path) { - if (!path) { - return false; - } - for (int i = 0; i < set->count; i++) { - if (set->paths[i] && strcmp(set->paths[i], path) == 0) { - return true; - } - } - return false; -} - static int incoming_edge_count(const cbm_gbuf_t *gb, int64_t id, const char *type) { const cbm_gbuf_edge_t **edges = NULL; int ne = 0; @@ -197,9 +175,6 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { return; } - cbm_test_file_set_t tfset; - test_file_set_build(gb, &tfset); - int updated = 0; for (int li = 0; li < CBM_IMPORTANCE_LABEL_COUNT; li++) { const cbm_gbuf_node_t **nodes = NULL; @@ -223,8 +198,8 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { if (name_is_distinctive(n->name)) { score *= CBM_IMPORTANCE_DISTINCT_MUL; } - bool is_test = incoming_edge_count(gb, n->id, "TESTS") > 0 || - test_file_set_contains(&tfset, n->file_path); + bool is_test = cbm_is_test_path(n->file_path) || + incoming_edge_count(gb, n->id, "TESTS") > 0; if (is_test) { score *= CBM_IMPORTANCE_TEST_MUL; } @@ -237,6 +212,4 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { char buf[CBM_SZ_32]; snprintf(buf, sizeof(buf), "%d", updated); cbm_log_info("pass.importance", "symbols", buf); - - free(tfset.paths); } From cce0f680dc18040ba7c419ae0ab4c40cd267cb2d Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Sun, 5 Jul 2026 11:42:39 +0100 Subject: [PATCH 4/8] style: apply clang-format-20 to importance pass and tests Signed-off-by: Peter Cox --- src/pipeline/pass_importance.c | 11 +++---- src/pipeline/pipeline.c | 9 ++++-- tests/test_importance.c | 54 ++++++++++++++++------------------ 3 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/pipeline/pass_importance.c b/src/pipeline/pass_importance.c index 96bf2314..51389868 100644 --- a/src/pipeline/pass_importance.c +++ b/src/pipeline/pass_importance.c @@ -160,7 +160,8 @@ void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) { return; } memcpy(neu, old, olen - 1); /* copy without the trailing '}' */ - int w = snprintf(neu + (olen - 1), CBM_SZ_64, "%s\"importance\":%.6f}", empty ? "" : ",", score); + int w = + snprintf(neu + (olen - 1), CBM_SZ_64, "%s\"importance\":%.6f}", empty ? "" : ",", score); if (w < 0) { free(neu); return; @@ -185,8 +186,8 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { for (int i = 0; i < count; i++) { cbm_gbuf_node_t *n = (cbm_gbuf_node_t *)nodes[i]; - int num_refs = incoming_edge_count(gb, n->id, "CALLS") + - incoming_edge_count(gb, n->id, "USAGE"); + int num_refs = + incoming_edge_count(gb, n->id, "CALLS") + incoming_edge_count(gb, n->id, "USAGE"); double score = sqrt((double)num_refs); if (name_is_private(n->name)) { @@ -198,8 +199,8 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) { if (name_is_distinctive(n->name)) { score *= CBM_IMPORTANCE_DISTINCT_MUL; } - bool is_test = cbm_is_test_path(n->file_path) || - incoming_edge_count(gb, n->id, "TESTS") > 0; + bool is_test = + cbm_is_test_path(n->file_path) || incoming_edge_count(gb, n->id, "TESTS") > 0; if (is_test) { score *= CBM_IMPORTANCE_TEST_MUL; } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 1d6f7d4c..82997ec2 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -707,9 +707,12 @@ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { const char *name; bool moderate_only; /* true = skip in fast mode */ } passes[] = { - {predump_deco, "decorator_tags", false}, {predump_cfg, "configlink", false}, - {predump_route, "route_match", false}, {predump_sim, "similarity", true}, - {predump_sem, "semantic_edges", true}, {predump_complexity, "complexity", false}, + {predump_deco, "decorator_tags", false}, + {predump_cfg, "configlink", false}, + {predump_route, "route_match", false}, + {predump_sim, "similarity", true}, + {predump_sem, "semantic_edges", true}, + {predump_complexity, "complexity", false}, /* MUST run after pass_tests (TESTS/TESTS_FILE edges, run_tests_and_history -- * always precedes run_predump_passes) and after CALLS/USAGE edges (populated * during sequential extraction, before run_post_extraction). Placed last so diff --git a/tests/test_importance.c b/tests/test_importance.c index af20f3ca..d01b0fc2 100644 --- a/tests/test_importance.c +++ b/tests/test_importance.c @@ -141,8 +141,8 @@ TEST(importance_base_sqrt_num_refs) { cbm_gbuf_upsert_node(gb, "Function", "caller1", "pkg.caller1", "pkg/main.go", 2, 2, "{}"); int64_t caller2 = cbm_gbuf_upsert_node(gb, "Function", "caller2", "pkg.caller2", "pkg/main.go", 3, 3, "{}"); - int64_t consumer = cbm_gbuf_upsert_node(gb, "Function", "consumer", "pkg.consumer", - "pkg/main.go", 4, 4, "{}"); + int64_t consumer = + cbm_gbuf_upsert_node(gb, "Function", "consumer", "pkg.consumer", "pkg/main.go", 4, 4, "{}"); int64_t lonely = cbm_gbuf_upsert_node(gb, "Function", "lonelyfn", "pkg.lonelyfn", "pkg/main.go", 5, 5, "{}"); ASSERT_GT(target, 0); @@ -192,16 +192,16 @@ TEST(importance_private_multiplier) { char name[32], qn[48]; snprintf(name, sizeof(name), "pcaller%d", i); snprintf(qn, sizeof(qn), "pkg.pcaller%d", i); - int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 10 + i, 10 + i, - "{}"); + int64_t c = + cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 10 + i, 10 + i, "{}"); cbm_gbuf_insert_edge(gb, c, priv_target, "CALLS", "{}"); } for (int i = 0; i < 4; i++) { char name[32], qn[48]; snprintf(name, sizeof(name), "qcaller%d", i); snprintf(qn, sizeof(qn), "pkg.qcaller%d", i); - int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 20 + i, 20 + i, - "{}"); + int64_t c = + cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 20 + i, 20 + i, "{}"); cbm_gbuf_insert_edge(gb, c, pub_target, "CALLS", "{}"); } @@ -309,8 +309,8 @@ TEST(importance_distinctive_identifier_multiplier) { for (int i = 0; i < N_CASES; i++) { char qn[64]; snprintf(qn, sizeof(qn), "pkg.%s", cases[i].name); - ids[i] = - cbm_gbuf_upsert_node(gb, "Function", cases[i].name, qn, "pkg/main.go", i + 1, i + 1, "{}"); + ids[i] = cbm_gbuf_upsert_node(gb, "Function", cases[i].name, qn, "pkg/main.go", i + 1, + i + 1, "{}"); char cname[32], cqn[64]; snprintf(cname, sizeof(cname), "caller_of_%d", i); snprintf(cqn, sizeof(cqn), "pkg.caller_of_%d", i); @@ -364,9 +364,9 @@ TEST(importance_test_penalty_edge_based) { /* (b) inversion/discriminating check: PATH contains "test" as a * substring but there is NO TESTS/TESTS_FILE edge -> must NOT be * penalized (proves the strstr path was truly replaced). */ - int64_t path_substr = cbm_gbuf_upsert_node(gb, "Function", "latest_helper_fn", - "pkg.latest_helper_fn", "src/testutil_helpers.go", 1, - 1, "{}"); + int64_t path_substr = + cbm_gbuf_upsert_node(gb, "Function", "latest_helper_fn", "pkg.latest_helper_fn", + "src/testutil_helpers.go", 1, 1, "{}"); int64_t ps_caller = cbm_gbuf_upsert_node(gb, "Function", "pscaller", "pkg.pscaller", "pkg/main.go", 3, 3, "{}"); cbm_gbuf_insert_edge(gb, ps_caller, path_substr, "CALLS", "{}"); @@ -375,17 +375,15 @@ TEST(importance_test_penalty_edge_based) { * TESTS_FILE file-level edge (pass_tests never emits a direct TESTS * edge here because the target already lives in a test-classified * file -- see create_tests_edges' tgt_is_test exclusion). */ - int64_t test_file_node = - cbm_gbuf_upsert_node(gb, "File", "recA_test.go", "pkg.__file__.recA_test.go", - "recA_test.go", 0, 0, "{}"); - int64_t prod_file_node = cbm_gbuf_upsert_node(gb, "File", "recA.go", "pkg.__file__.recA.go", - "recA.go", 0, 0, "{}"); + int64_t test_file_node = cbm_gbuf_upsert_node( + gb, "File", "recA_test.go", "pkg.__file__.recA_test.go", "recA_test.go", 0, 0, "{}"); + int64_t prod_file_node = + cbm_gbuf_upsert_node(gb, "File", "recA.go", "pkg.__file__.recA.go", "recA.go", 0, 0, "{}"); cbm_gbuf_insert_edge(gb, test_file_node, prod_file_node, "TESTS_FILE", "{}"); int64_t seed_fn = cbm_gbuf_upsert_node(gb, "Function", "seed_recording_fn", "pkg.seed_recording_fn", "recA_test.go", 5, 5, "{}"); - int64_t seed_caller = - cbm_gbuf_upsert_node(gb, "Function", "seedcaller", "pkg.seedcaller", "pkg/main.go", 4, 4, - "{}"); + int64_t seed_caller = cbm_gbuf_upsert_node(gb, "Function", "seedcaller", "pkg.seedcaller", + "pkg/main.go", 4, 4, "{}"); cbm_gbuf_insert_edge(gb, seed_caller, seed_fn, "CALLS", "{}"); atomic_int cancelled = 0; @@ -396,13 +394,13 @@ TEST(importance_test_penalty_edge_based) { /* num_refs=1, distinct(snake,len>=8)=10 for all of these -> the only * variable is the test_penalty factor (0.1 penalized, 1.0 not). */ ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, helper)->properties_json), - sqrt(1.0) * 10.0 * 0.1, 1e-6); + sqrt(1.0) * 10.0 * 0.1, 1e-6); ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, control)->properties_json), - sqrt(1.0) * 10.0, 1e-6); + sqrt(1.0) * 10.0, 1e-6); ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, path_substr)->properties_json), - sqrt(1.0) * 10.0, 1e-6); /* NOT penalized despite "test" substring */ + sqrt(1.0) * 10.0, 1e-6); /* NOT penalized despite "test" substring */ ASSERT_FLOAT_EQ(extract_importance(cbm_gbuf_find_by_id(gb, seed_fn)->properties_json), - sqrt(1.0) * 10.0 * 0.1, 1e-6); /* penalized via TESTS_FILE membership */ + sqrt(1.0) * 10.0 * 0.1, 1e-6); /* penalized via TESTS_FILE membership */ cbm_gbuf_free(gb); PASS(); @@ -420,9 +418,9 @@ TEST(importance_ordering_after_tests_and_calls) { snprintf(tmpdir, sizeof(tmpdir), "%s", tmp); const char *names[] = {"helpers.go", "main_test.go"}; - const char *contents[] = { - "package main\n\nfunc make_fixture_fn() {}\n", - "package main\n\nimport \"testing\"\n\nfunc TestFixture(t *testing.T) { make_fixture_fn() }\n"}; + const char *contents[] = {"package main\n\nfunc make_fixture_fn() {}\n", + "package main\n\nimport \"testing\"\n\nfunc TestFixture(t " + "*testing.T) { make_fixture_fn() }\n"}; if (write_files(tmpdir, names, contents, 2) != 0) { th_rmtree(tmpdir); FAIL("write fixture"); @@ -570,8 +568,8 @@ TEST(importance_properties_json_valid_after_append) { cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test"); ASSERT_NOT_NULL(gb); - int64_t a = cbm_gbuf_upsert_node(gb, "Function", "alpha_fn", "pkg.alpha_fn", "pkg/a.go", 1, 1, - "{}"); + int64_t a = + cbm_gbuf_upsert_node(gb, "Function", "alpha_fn", "pkg.alpha_fn", "pkg/a.go", 1, 1, "{}"); int64_t b = cbm_gbuf_upsert_node(gb, "Function", "beta_fn", "pkg.beta_fn", "pkg/b.go", 1, 1, "{\"docstring\":\"hi\"}"); int64_t caller = From 2750d1e19afb2d56c3f616079e760f266be8562b Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Thu, 2 Jul 2026 00:08:44 +0100 Subject: [PATCH 5/8] red tests for P4 repo_map query tool 26 failing AC tests derived from the P4 test plan (rows 1-5, 8-13): registration/dispatch, token-budget fit (default/tight/tiny/huge/invalid), seed-boost ranking + inversion, weak-seed widen walk, empty/unresolvable-seed global fallback, score-absence gating, graceful input errors, signature-level rendering, determinism, cross-project isolation, repeated-call stability. All 26 verified FAILING against unmodified code (unknown tool). Five tests carry explicit positive discriminators so the dispatch-level error cannot trivially satisfy them. Rows 6-7 (real corpus, latency) are scripted post- implementation against a worktree binary + scratch CBM_CACHE_DIR. Pre-existing failure (not this change): test_incremental.c:302 RSS threshold (2854-2872MB vs 2048 cap under ASan), file last touched pre-base (eed87fd). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KK6YFYGwhT69NjYevuEUCr Signed-off-by: Peter Cox --- tests/test_mcp.c | 785 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index c1feebcf..823ce9f9 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4621,6 +4621,763 @@ TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853) { #endif } +/* ══════════════════════════════════════════════════════════════════ + * REPO_MAP — P4 token-budgeted, seed-aware query tool + * (pai/p4-repo-map-query-tool test plan; pinned ACs: AC2, AC6; AC7 tool + * legs in scope). All fixtures use an in-memory store pre-opened via + * cbm_mcp_server_set_project so resolve_store's "already open" shortcut + * serves the fixture data without touching disk (mirrors + * tool_get_architecture_emits_populated_sections above). + * ══════════════════════════════════════════════════════════════════ */ + +/* Create an in-memory server with `project` pre-registered (both as the + * server's current_project — so resolve_store never hits disk — and as a + * row in the `projects` table, so verify_project_indexed passes). */ +static cbm_mcp_server_t *rm_setup_server(const char *project) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, project); + cbm_store_upsert_project(st, project, "/tmp/repo-map-test"); + return srv; +} + +/* Upsert a scored Function/Method/Class fixture node. `signature`, when + * non-NULL, is stored verbatim as the "signature" property (repo_map + * renders it directly — see rm_render rule documented on handle_repo_map). + * Returns the node id. */ +static int64_t rm_add_node(cbm_store_t *st, const char *project, const char *label, + const char *name, const char *qn, const char *file_path, + double importance, const char *signature) { + char props[512]; + if (signature) { + snprintf(props, sizeof(props), "{\"importance\":%.6f,\"signature\":\"%s\"}", importance, + signature); + } else { + snprintf(props, sizeof(props), "{\"importance\":%.6f}", importance); + } + cbm_node_t n = {0}; + n.project = project; + n.label = label; + n.name = name; + n.qualified_name = qn; + n.file_path = file_path; + n.start_line = 1; + n.end_line = 2; + n.properties_json = props; + return cbm_store_upsert_node(st, &n); +} + +/* Upsert a Function/Method/Class fixture node with NO "importance" key — + * the exact shape of every pre-P3 index (spec AC7's score-absence case). */ +static int64_t rm_add_node_no_score(cbm_store_t *st, const char *project, const char *label, + const char *name, const char *qn, const char *file_path) { + cbm_node_t n = {0}; + n.project = project; + n.label = label; + n.name = name; + n.qualified_name = qn; + n.file_path = file_path; + n.start_line = 1; + n.end_line = 2; + n.properties_json = "{}"; + return cbm_store_upsert_node(st, &n); +} + +static void rm_add_edge(cbm_store_t *st, const char *project, int64_t src, int64_t dst, + const char *type) { + cbm_edge_t e = {0}; + e.project = project; + e.source_id = src; + e.target_id = dst; + e.type = type; + e.properties_json = "{}"; + cbm_store_insert_edge(st, &e); +} + +/* Three plain, unconnected symbols with distinct importance — the smallest + * fixture that has a well-defined global ranking. */ +static void rm_add_simple_fixture(cbm_store_t *st, const char *project) { + rm_add_node(st, project, "Function", "A", "qA", "pkg/a.go", 50.0, "A() error"); + rm_add_node(st, project, "Function", "B", "qB", "pkg/b.go", 40.0, "B() error"); + rm_add_node(st, project, "Function", "C", "qC", "pkg/c.go", 30.0, "C() error"); +} + +enum { RM_FANOUT_COUNT = 30 }; + +/* 30 symbols in one file, descending importance, with a real-shaped + * signature — used to exercise the token-budget binary search (AC2a). */ +static void rm_add_budget_fixture(cbm_store_t *st, const char *project) { + char name[64]; + char qn[96]; + char sig[96]; + for (int i = 0; i < RM_FANOUT_COUNT; i++) { + snprintf(name, sizeof(name), "f%02d", i); + snprintf(qn, sizeof(qn), "q.f%02d", i); + snprintf(sig, sizeof(sig), "f%02d(a, b, c int) (int, error)", i); + rm_add_node(st, project, "Function", name, qn, "pkg/file.go", + (double)(RM_FANOUT_COUNT - i), sig); + } +} + +/* Call repo_map and return the extracted inner JSON text (caller frees). */ +static char *rm_call(cbm_mcp_server_t *srv, const char *args_json) { + char *raw = cbm_mcp_handle_tool(srv, "repo_map", args_json); + char *text = extract_text_content(raw); + free(raw); + return text; +} + +/* Read an integer field out of a repo_map response's inner JSON text. -1 if absent. */ +static long rm_json_int(const char *json, const char *key) { + if (!json) { + return -1; + } + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) { + return -1; + } + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *val = root ? yyjson_obj_get(root, key) : NULL; + long result = (val && yyjson_is_int(val)) ? (long)yyjson_get_int(val) : -1; + yyjson_doc_free(doc); + return result; +} + +/* Read a string field out of a repo_map response's inner JSON text (caller frees). NULL if absent. */ +static char *rm_json_str(const char *json, const char *key) { + if (!json) { + return NULL; + } + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + if (!doc) { + return NULL; + } + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *val = root ? yyjson_obj_get(root, key) : NULL; + char *result = (val && yyjson_is_str(val)) ? strdup(yyjson_get_str(val)) : NULL; + yyjson_doc_free(doc); + return result; +} + +/* ── Row 1: registration + dispatch ──────────────────────────────── */ + +TEST(repo_map_registered_in_tools_list) { + char *json = cbm_mcp_tools_list(); + ASSERT_NOT_NULL(json); + const char *p = strstr(json, "\"repo_map\""); + ASSERT_NOT_NULL(p); + ASSERT_NOT_NULL(strstr(p, "\"project\"")); + ASSERT_NOT_NULL(strstr(p, "\"seed_anchors\"")); + ASSERT_NOT_NULL(strstr(p, "\"token_budget\"")); + free(json); + PASS(); +} + +TEST(repo_map_dispatchable_via_full_jsonrpc) { + cbm_mcp_server_t *srv = rm_setup_server("rm-dispatch"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_node(st, "rm-dispatch", "Function", "Foo", "rm-dispatch.Foo", "pkg/foo.go", 5.0, + "Foo() error"); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":500,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"repo_map\",\"arguments\":{\"project\":\"rm-dispatch\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"id\":500")); + ASSERT_NULL(strstr(resp, "\"isError\":true")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"map\"")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 2: AC2a token-budget fit ────────────────────────────────── */ + +TEST(repo_map_budget_default_applies_when_absent) { + cbm_mcp_server_t *srv = rm_setup_server("rm-budget-default"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_budget_fixture(st, "rm-budget-default"); + + char *text = rm_call(srv, "{\"project\":\"rm-budget-default\"}"); + ASSERT_NOT_NULL(text); + ASSERT_EQ(rm_json_int(text, "budget"), 1600); + long est = rm_json_int(text, "estimated_tokens"); + ASSERT_TRUE(est >= 0 && est <= 1600); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_budget_fits_and_uses_available_space) { + cbm_mcp_server_t *srv = rm_setup_server("rm-budget-tight"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_budget_fixture(st, "rm-budget-tight"); + + char *text = rm_call(srv, "{\"project\":\"rm-budget-tight\",\"token_budget\":100}"); + ASSERT_NOT_NULL(text); + long est = rm_json_int(text, "estimated_tokens"); + long cnt = rm_json_int(text, "symbol_count"); + ASSERT_TRUE(est >= 0 && est <= 100); + /* Content is plentiful (30 lines >> budget) — the binary search must + * converge UP to close to the ceiling, not truncate to a tiny result. */ + ASSERT_TRUE(est >= 50); + ASSERT_TRUE(cnt > 0 && cnt < RM_FANOUT_COUNT); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_budget_tiny_no_overshoot_no_hang) { + cbm_mcp_server_t *srv = rm_setup_server("rm-budget-tiny"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_budget_fixture(st, "rm-budget-tiny"); + + char *text = rm_call(srv, "{\"project\":\"rm-budget-tiny\",\"token_budget\":64}"); + ASSERT_NOT_NULL(text); + long est = rm_json_int(text, "estimated_tokens"); + long cnt = rm_json_int(text, "symbol_count"); + ASSERT_TRUE(est >= 0 && est <= 64); + ASSERT_TRUE(cnt >= 0 && cnt < RM_FANOUT_COUNT); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_budget_larger_than_whole_map_returns_everything) { + cbm_mcp_server_t *srv = rm_setup_server("rm-budget-huge"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_budget_fixture(st, "rm-budget-huge"); + + char *text = rm_call(srv, "{\"project\":\"rm-budget-huge\",\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + ASSERT_EQ(rm_json_int(text, "symbol_count"), RM_FANOUT_COUNT); + ASSERT_NOT_NULL(strstr(text, "f00(")); + ASSERT_NOT_NULL(strstr(text, "f29(")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_budget_zero_or_negative_is_input_error) { + cbm_mcp_server_t *srv = rm_setup_server("rm-budget-bad"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_budget_fixture(st, "rm-budget-bad"); + + char *raw = cbm_mcp_handle_tool(srv, "repo_map", + "{\"project\":\"rm-budget-bad\",\"token_budget\":0}"); + ASSERT_NOT_NULL(raw); + ASSERT_NOT_NULL(strstr(raw, "\"isError\":true")); + /* Discriminating: must be the tool's own budget validation, not the + * dispatch-level "unknown tool" error (red-boundary trivial-pass guard). */ + ASSERT_NOT_NULL(strstr(raw, "token_budget")); + ASSERT_NULL(strstr(raw, "unknown tool")); + free(raw); + + raw = cbm_mcp_handle_tool(srv, "repo_map", + "{\"project\":\"rm-budget-bad\",\"token_budget\":-5}"); + ASSERT_NOT_NULL(raw); + ASSERT_NOT_NULL(strstr(raw, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(raw, "token_budget")); + ASSERT_NULL(strstr(raw, "unknown tool")); + free(raw); + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 3: AC2b seed-boost ranking ──────────────────────────────── */ + +/* Two clusters: a HIGH-raw-importance "distant" trio with no relation to the + * seed, and a MODEST-raw-importance "seed" cluster (S + 4 CALLS/USAGE + * neighbours — deliberately > REPO_MAP_WEAK_NEIGHBOR_THRESHOLD so the widen + * path (row 4) does not fire here). */ +static void rm_add_seed_boost_fixture(cbm_store_t *st, const char *project) { + rm_add_node(st, project, "Function", "D0", "proj.D0", "pkg/distant.go", 100.0, "D0() error"); + rm_add_node(st, project, "Function", "D1", "proj.D1", "pkg/distant.go", 90.0, "D1() error"); + rm_add_node(st, project, "Function", "D2", "proj.D2", "pkg/distant.go", 80.0, "D2() error"); + + int64_t s = rm_add_node(st, project, "Function", "S", "proj.S", "pkg/seed.go", 5.0, + "S() error"); + int64_t n1 = + rm_add_node(st, project, "Function", "N1", "proj.N1", "pkg/seed_n.go", 4.0, "N1() error"); + int64_t n2 = + rm_add_node(st, project, "Function", "N2", "proj.N2", "pkg/seed_n.go", 4.0, "N2() error"); + int64_t n3 = + rm_add_node(st, project, "Function", "N3", "proj.N3", "pkg/seed_n.go", 3.0, "N3() error"); + int64_t n4 = + rm_add_node(st, project, "Function", "N4", "proj.N4", "pkg/seed_n.go", 3.0, "N4() error"); + + rm_add_edge(st, project, s, n1, "CALLS"); + rm_add_edge(st, project, s, n2, "CALLS"); + rm_add_edge(st, project, n3, s, "CALLS"); /* inbound direction too */ + rm_add_edge(st, project, s, n4, "USAGE"); +} + +TEST(repo_map_seed_boost_ranks_neighbourhood_above_distant) { + cbm_mcp_server_t *srv = rm_setup_server("rm-seed-boost"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_seed_boost_fixture(st, "rm-seed-boost"); + + /* Seeded: S's neighbourhood must outrank the distant high-importance cluster. */ + char *seeded = + rm_call(srv, "{\"project\":\"rm-seed-boost\",\"seed_anchors\":[\"S\"]," + "\"token_budget\":100000}"); + ASSERT_NOT_NULL(seeded); + const char *s_pos = strstr(seeded, "S() error"); + const char *d0_pos = strstr(seeded, "D0() error"); + ASSERT_NOT_NULL(s_pos); + ASSERT_NOT_NULL(d0_pos); + ASSERT_TRUE(s_pos < d0_pos); + + /* No seeds: inversion — raw importance dominates, distant wins. */ + char *global = rm_call(srv, "{\"project\":\"rm-seed-boost\",\"token_budget\":100000}"); + ASSERT_NOT_NULL(global); + const char *g_s_pos = strstr(global, "S() error"); + const char *g_d0_pos = strstr(global, "D0() error"); + ASSERT_NOT_NULL(g_s_pos); + ASSERT_NOT_NULL(g_d0_pos); + ASSERT_TRUE(g_d0_pos < g_s_pos); + + free(seeded); + free(global); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_tight_budget_seed_crowds_out_distant) { + cbm_mcp_server_t *srv = rm_setup_server("rm-seed-tight"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_seed_boost_fixture(st, "rm-seed-tight"); + + /* Budget fits only the top ~2 lines of the seeded ranking — since the + * whole 5-member seed cluster outranks the distant trio, none of the + * distant symbols can appear at all. */ + char *text = + rm_call(srv, "{\"project\":\"rm-seed-tight\",\"seed_anchors\":[\"S\"]," + "\"token_budget\":15}"); + ASSERT_NOT_NULL(text); + /* Positive discriminator first: the top seeded symbol IS in the map + * (guards the red-boundary trivial pass where an error response also + * "contains no D0"). */ + ASSERT_NOT_NULL(strstr(text, "S() error")); + ASSERT_NULL(strstr(text, "D0() error")); + ASSERT_NULL(strstr(text, "D1() error")); + ASSERT_NULL(strstr(text, "D2() error")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_seed_by_file_path) { + cbm_mcp_server_t *srv = rm_setup_server("rm-seed-file"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_seed_boost_fixture(st, "rm-seed-file"); + + char *text = rm_call(srv, "{\"project\":\"rm-seed-file\",\"seed_anchors\":[\"pkg/seed.go\"]," + "\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + const char *s_pos = strstr(text, "S() error"); + const char *d0_pos = strstr(text, "D0() error"); + ASSERT_NOT_NULL(s_pos); + ASSERT_NOT_NULL(d0_pos); + ASSERT_TRUE(s_pos < d0_pos); + ASSERT_NOT_NULL(strstr(text, "\"mode\":\"seeded\"")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_seed_resolves_multiple_nodes) { + cbm_mcp_server_t *srv = rm_setup_server("rm-seed-multi"); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *project = "rm-seed-multi"; + + rm_add_node(st, project, "Function", "Hi", "proj.pkgA.Hi", "pkg/a.go", 50.0, "Hi() error"); + rm_add_node(st, project, "Function", "Dup", "proj.pkgB.Dup", "pkg/b.go", 2.0, "DupB() error"); + rm_add_node(st, project, "Function", "Dup", "proj.pkgC.Dup", "pkg/c.go", 2.0, "DupC() error"); + + char *text = rm_call(srv, "{\"project\":\"rm-seed-multi\",\"seed_anchors\":[\"Dup\"]," + "\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "\"seed_anchors_resolved\":1")); + const char *dupb_pos = strstr(text, "DupB() error"); + const char *dupc_pos = strstr(text, "DupC() error"); + const char *hi_pos = strstr(text, "Hi() error"); + ASSERT_NOT_NULL(dupb_pos); + ASSERT_NOT_NULL(dupc_pos); + ASSERT_NOT_NULL(hi_pos); + /* Both nodes resolved by the shared name "Dup" are boosted (2*50=100) + * above the unrelated higher-raw-importance "Hi" (50). */ + ASSERT_TRUE(dupb_pos < hi_pos); + ASSERT_TRUE(dupc_pos < hi_pos); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 4: AC2b sharpening — weak-seed widen ────────────────────── */ + +TEST(repo_map_weak_seed_triggers_widen_walk) { + /* Builder's chosen widen rule (also documented on handle_repo_map in + * mcp.c): when a seed's 1-hop CALLS|USAGE neighbourhood has + * <= REPO_MAP_WEAK_NEIGHBOR_THRESHOLD (3) members, widen via (a) + * file-of-symbol seeding — every other symbol defined in the seed's own + * file — and (b) one more hop from the 1-hop neighbours (2-hop + * expansion). Both widened sets get REPO_MAP_WIDEN_BOOST (25x) vs the + * direct seed/1-hop REPO_MAP_SEED_BOOST (50x). Here W has exactly ONE + * 1-hop neighbour (W1), so widen fires. */ + cbm_mcp_server_t *srv = rm_setup_server("rm-weak-seed"); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *project = "rm-weak-seed"; + + int64_t w = rm_add_node(st, project, "Function", "W", "rm-weak-seed.W", "pkg/weak.go", 1.0, + "W() error"); + int64_t w1 = rm_add_node(st, project, "Function", "W1", "rm-weak-seed.W1", "pkg/other.go", + 1.0, "W1() error"); + rm_add_node(st, project, "Function", "WSibling", "rm-weak-seed.WSibling", "pkg/weak.go", 1.0, + "WSibling() error"); + int64_t w1a = rm_add_node(st, project, "Function", "W1a", "rm-weak-seed.W1a", + "pkg/other2.go", 1.0, "W1a() error"); + /* Raw importance between the widen boost (1*25=25) and the strong seed + * boost (1*50=50) — proves the widen-boosted symbols outrank a *higher* + * raw-importance unrelated symbol, not just "small graph, all fits". */ + rm_add_node(st, project, "Function", "Filler", "rm-weak-seed.Filler", "pkg/filler.go", 10.0, + "Filler() error"); + + rm_add_edge(st, project, w, w1, "CALLS"); + rm_add_edge(st, project, w1, w1a, "CALLS"); + + char *text = rm_call(srv, "{\"project\":\"rm-weak-seed\",\"seed_anchors\":[\"W\"]," + "\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + const char *wsib_pos = strstr(text, "WSibling() error"); + const char *w1a_pos = strstr(text, "W1a() error"); + const char *filler_pos = strstr(text, "Filler() error"); + ASSERT_NOT_NULL(wsib_pos); /* file-of-symbol widen member present */ + ASSERT_NOT_NULL(w1a_pos); /* 2-hop widen member present */ + ASSERT_NOT_NULL(filler_pos); + ASSERT_TRUE(wsib_pos < filler_pos); + ASSERT_TRUE(w1a_pos < filler_pos); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 5: AC2c empty/unusable seeds → global map ───────────────── */ + +TEST(repo_map_no_seed_and_empty_seed_and_all_unresolvable_yield_identical_global_map) { + cbm_mcp_server_t *srv = rm_setup_server("rm-empty-seed"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_simple_fixture(st, "rm-empty-seed"); + + char *no_seed = rm_call(srv, "{\"project\":\"rm-empty-seed\",\"token_budget\":100000}"); + char *empty_arr = rm_call( + srv, "{\"project\":\"rm-empty-seed\",\"seed_anchors\":[],\"token_budget\":100000}"); + char *all_unresolvable = + rm_call(srv, "{\"project\":\"rm-empty-seed\",\"seed_anchors\":[\"nonexistent_xyz\"]," + "\"token_budget\":100000}"); + ASSERT_NOT_NULL(no_seed); + ASSERT_NOT_NULL(empty_arr); + ASSERT_NOT_NULL(all_unresolvable); + + char *no_seed_map = rm_json_str(no_seed, "map"); + char *empty_map = rm_json_str(empty_arr, "map"); + char *unresolvable_map = rm_json_str(all_unresolvable, "map"); + ASSERT_NOT_NULL(no_seed_map); + ASSERT_NOT_NULL(empty_map); + ASSERT_NOT_NULL(unresolvable_map); + ASSERT_STR_EQ(no_seed_map, empty_map); + ASSERT_STR_EQ(no_seed_map, unresolvable_map); + + ASSERT_NOT_NULL(strstr(no_seed, "\"mode\":\"global\"")); + ASSERT_NOT_NULL(strstr(empty_arr, "\"mode\":\"global\"")); + ASSERT_NOT_NULL(strstr(all_unresolvable, "\"mode\":\"global\"")); + + free(no_seed_map); + free(empty_map); + free(unresolvable_map); + free(no_seed); + free(empty_arr); + free(all_unresolvable); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_mixed_resolvable_and_unresolvable_seeds_uses_seeded_mode) { + cbm_mcp_server_t *srv = rm_setup_server("rm-mixed-seed"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_simple_fixture(st, "rm-mixed-seed"); + + char *text = + rm_call(srv, "{\"project\":\"rm-mixed-seed\",\"seed_anchors\":[\"nonexistent_xyz\"," + "\"A\"],\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "\"mode\":\"seeded\"")); + ASSERT_NOT_NULL(strstr(text, "\"seed_anchors_requested\":2")); + ASSERT_NOT_NULL(strstr(text, "\"seed_anchors_resolved\":1")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 8: spec AC7 score-absence gating ────────────────────────── */ + +TEST(repo_map_unscored_project_returns_explicit_gate_error) { + cbm_mcp_server_t *srv = rm_setup_server("rm-unscored"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_node_no_score(st, "rm-unscored", "Function", "Foo", "qFoo", "pkg/foo.go"); + rm_add_node_no_score(st, "rm-unscored", "Function", "Bar", "qBar", "pkg/bar.go"); + + char *raw = cbm_mcp_handle_tool(srv, "repo_map", "{\"project\":\"rm-unscored\"}"); + ASSERT_NOT_NULL(raw); + ASSERT_NOT_NULL(strstr(raw, "\"isError\":true")); + char *inner = extract_text_content(raw); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "unscored")); + free(inner); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_partial_score_population_is_not_gated) { + /* One Function with no importance (pre-P3 leftover) alongside one Class + * WITH a persisted score — spec AC7's "partial population" sub-case. + * Documented behaviour: the gate fires only when NO node is scored at + * all; a partially-scored project proceeds (unscored nodes rank as 0 — + * not a crash, not a silently-unranked map). */ + cbm_mcp_server_t *srv = rm_setup_server("rm-partial-score"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_node_no_score(st, "rm-partial-score", "Function", "Unscored", "qU", "pkg/u.go"); + rm_add_node(st, "rm-partial-score", "Class", "Scored", "qS", "pkg/s.go", 10.0, "Scored()"); + + char *raw = cbm_mcp_handle_tool(srv, "repo_map", "{\"project\":\"rm-partial-score\"}"); + ASSERT_NOT_NULL(raw); + ASSERT_NULL(strstr(raw, "\"isError\":true")); + char *inner = extract_text_content(raw); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "Scored()")); + free(inner); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 9: spec AC7 graceful input/error paths ──────────────────── */ + +TEST(repo_map_missing_project_is_error_no_crash) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *raw = cbm_mcp_handle_tool(srv, "repo_map", "{}"); + ASSERT_NOT_NULL(raw); + ASSERT_NOT_NULL(strstr(raw, "\"isError\":true")); + /* Discriminating: must be the tool's own project-missing error, not + * dispatch-level "unknown tool" (red-boundary trivial-pass guard). */ + ASSERT_NULL(strstr(raw, "unknown tool")); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_unknown_project_require_store_error) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *raw = cbm_mcp_handle_tool(srv, "repo_map", "{\"project\":\"totally-unknown-xyz\"}"); + ASSERT_NOT_NULL(raw); + ASSERT_TRUE(strstr(raw, "not found") != NULL || strstr(raw, "not indexed") != NULL); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_indexed_false_project_verify_indexed_error) { + /* current_project pre-set (resolve_store's cache shortcut returns a + * non-NULL store) but no row was ever upserted into `projects` for this + * name — exercises verify_project_indexed's error path specifically, + * distinct from REQUIRE_STORE's file-not-found path above. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_mcp_server_set_project(srv, "ghost-project"); + + char *raw = cbm_mcp_handle_tool(srv, "repo_map", "{\"project\":\"ghost-project\"}"); + ASSERT_NOT_NULL(raw); + ASSERT_NOT_NULL(strstr(raw, "not indexed")); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_malformed_seed_anchors_string_coerced) { + cbm_mcp_server_t *srv = rm_setup_server("rm-malformed-1"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_simple_fixture(st, "rm-malformed-1"); + + /* seed_anchors as a bare string instead of an array — coerced into a + * single-element list (documented leniency, not an error). */ + char *raw = cbm_mcp_handle_tool(srv, "repo_map", + "{\"project\":\"rm-malformed-1\",\"seed_anchors\":\"A\"}"); + ASSERT_NOT_NULL(raw); + ASSERT_NULL(strstr(raw, "\"isError\":true")); + char *inner = extract_text_content(raw); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"mode\":\"seeded\"")); + free(inner); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_malformed_seed_anchors_non_string_elements_skipped) { + cbm_mcp_server_t *srv = rm_setup_server("rm-malformed-2"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_simple_fixture(st, "rm-malformed-2"); + + char *raw = cbm_mcp_handle_tool( + srv, "repo_map", "{\"project\":\"rm-malformed-2\",\"seed_anchors\":[1,2,\"A\"]}"); + ASSERT_NOT_NULL(raw); + ASSERT_NULL(strstr(raw, "\"isError\":true")); + char *inner = extract_text_content(raw); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"mode\":\"seeded\"")); + ASSERT_NOT_NULL(strstr(inner, "\"seed_anchors_resolved\":1")); + free(inner); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(repo_map_absurdly_long_seed_list_capped_no_hang) { + cbm_mcp_server_t *srv = rm_setup_server("rm-malformed-3"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_simple_fixture(st, "rm-malformed-3"); + + /* 200 bogus seed names — well past REPO_MAP_MAX_SEEDS (50). Must not + * hang or crash; excess entries are silently dropped. */ + char buf[4096] = "{\"project\":\"rm-malformed-3\",\"seed_anchors\":["; + size_t pos = strlen(buf); + for (int i = 0; i < 200; i++) { + char frag[32]; + int n = snprintf(frag, sizeof(frag), "%s\"bogus%d\"", i > 0 ? "," : "", i); + if (n < 0 || pos + (size_t)n >= sizeof(buf) - 4) { + break; + } + memcpy(buf + pos, frag, (size_t)n); + pos += (size_t)n; + } + memcpy(buf + pos, "]}", 3); + + char *raw = cbm_mcp_handle_tool(srv, "repo_map", buf); + ASSERT_NOT_NULL(raw); + ASSERT_NULL(strstr(raw, "\"isError\":true")); + free(raw); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 10: signature-level rendering, no bodies ────────────────── */ + +TEST(repo_map_renders_signature_level_no_body_leak) { + cbm_mcp_server_t *srv = rm_setup_server("rm-render"); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *project = "rm-render"; + + cbm_node_t n = {0}; + n.project = project; + n.label = "Function"; + n.name = "Foo"; + n.qualified_name = "rm-render.Foo"; + n.file_path = "pkg/foo.go"; + n.start_line = 1; + n.end_line = 5; + n.properties_json = "{\"importance\":10.0,\"signature\":\"Foo(x int) error\"," + "\"body_preview\":\"BODY_MARKER_DO_NOT_LEAK do_something()\"}"; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + + char *text = rm_call(srv, "{\"project\":\"rm-render\"}"); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "pkg/foo.go: Foo(x int) error")); + ASSERT_NULL(strstr(text, "BODY_MARKER_DO_NOT_LEAK")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 11: determinism ──────────────────────────────────────────── */ + +TEST(repo_map_deterministic_byte_identical_across_calls) { + cbm_mcp_server_t *srv = rm_setup_server("rm-determinism"); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *project = "rm-determinism"; + /* Two tied scores force the tie-break rule (qualified_name ASC) to do + * the work — the case where sort stability actually matters. */ + rm_add_node(st, project, "Function", "Tie1", "qTie1", "pkg/x.go", 5.0, "Tie1() error"); + rm_add_node(st, project, "Function", "Tie2", "qTie2", "pkg/x.go", 5.0, "Tie2() error"); + rm_add_node(st, project, "Function", "Other", "qOther", "pkg/y.go", 3.0, "Other() error"); + + char *first = rm_call(srv, "{\"project\":\"rm-determinism\",\"token_budget\":100000}"); + char *second = rm_call(srv, "{\"project\":\"rm-determinism\",\"token_budget\":100000}"); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + /* Positive discriminator: a real map with the tied pair rendered in + * tie-break order (guards the red-boundary trivial pass where two + * identical error strings compare equal). */ + const char *tie1_pos = strstr(first, "Tie1() error"); + const char *tie2_pos = strstr(first, "Tie2() error"); + ASSERT_NOT_NULL(tie1_pos); + ASSERT_NOT_NULL(tie2_pos); + ASSERT_TRUE(tie1_pos < tie2_pos); /* qualified_name ASC: qTie1 < qTie2 */ + ASSERT_STR_EQ(first, second); + free(first); + free(second); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 12: no cross-project leakage ────────────────────────────── */ + +TEST(repo_map_no_cross_project_leakage) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_store_upsert_project(st, "projA", "/tmp/projA"); + cbm_store_upsert_project(st, "projB", "/tmp/projB"); + rm_add_node(st, "projA", "Function", "FnA", "projA.FnA", "pkg/a.go", 10.0, "FnA() error"); + rm_add_node(st, "projB", "Function", "FnB", "projB.FnB", "pkg/b.go", 10.0, "FnB() error"); + + cbm_mcp_server_set_project(srv, "projA"); + char *text = rm_call(srv, "{\"project\":\"projA\",\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + ASSERT_NOT_NULL(strstr(text, "FnA() error")); + ASSERT_NULL(strstr(text, "FnB() error")); + ASSERT_NULL(strstr(text, "pkg/b.go")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ── Row 13: memory/lifecycle hygiene ─────────────────────────────── */ + +TEST(repo_map_repeated_calls_stable_no_leak_surface) { + cbm_mcp_server_t *srv = rm_setup_server("rm-repeat"); + cbm_store_t *st = cbm_mcp_server_store(srv); + rm_add_simple_fixture(st, "rm-repeat"); + + char *baseline = rm_call(srv, "{\"project\":\"rm-repeat\",\"token_budget\":100000}"); + ASSERT_NOT_NULL(baseline); + /* Positive discriminator: baseline is a real map, not a repeated + * identical error response (red-boundary trivial-pass guard). */ + ASSERT_NOT_NULL(strstr(baseline, "A() error")); + for (int i = 0; i < 20; i++) { + char *text = rm_call(srv, "{\"project\":\"rm-repeat\",\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + ASSERT_STR_EQ(baseline, text); + free(text); + } + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + free(baseline); + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -4802,4 +5559,32 @@ SUITE(mcp) { RUN_TEST(mcp_auto_watch_default_registers_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853); + + /* repo_map (P4 token-budgeted, seed-aware query tool) */ + RUN_TEST(repo_map_registered_in_tools_list); + RUN_TEST(repo_map_dispatchable_via_full_jsonrpc); + RUN_TEST(repo_map_budget_default_applies_when_absent); + RUN_TEST(repo_map_budget_fits_and_uses_available_space); + RUN_TEST(repo_map_budget_tiny_no_overshoot_no_hang); + RUN_TEST(repo_map_budget_larger_than_whole_map_returns_everything); + RUN_TEST(repo_map_budget_zero_or_negative_is_input_error); + RUN_TEST(repo_map_seed_boost_ranks_neighbourhood_above_distant); + RUN_TEST(repo_map_tight_budget_seed_crowds_out_distant); + RUN_TEST(repo_map_seed_by_file_path); + RUN_TEST(repo_map_seed_resolves_multiple_nodes); + RUN_TEST(repo_map_weak_seed_triggers_widen_walk); + RUN_TEST(repo_map_no_seed_and_empty_seed_and_all_unresolvable_yield_identical_global_map); + RUN_TEST(repo_map_mixed_resolvable_and_unresolvable_seeds_uses_seeded_mode); + RUN_TEST(repo_map_unscored_project_returns_explicit_gate_error); + RUN_TEST(repo_map_partial_score_population_is_not_gated); + RUN_TEST(repo_map_missing_project_is_error_no_crash); + RUN_TEST(repo_map_unknown_project_require_store_error); + RUN_TEST(repo_map_indexed_false_project_verify_indexed_error); + RUN_TEST(repo_map_malformed_seed_anchors_string_coerced); + RUN_TEST(repo_map_malformed_seed_anchors_non_string_elements_skipped); + RUN_TEST(repo_map_absurdly_long_seed_list_capped_no_hang); + RUN_TEST(repo_map_renders_signature_level_no_body_leak); + RUN_TEST(repo_map_deterministic_byte_identical_across_calls); + RUN_TEST(repo_map_no_cross_project_leakage); + RUN_TEST(repo_map_repeated_calls_stable_no_leak_surface); } From 35caa23b408bba6e5fee0ed52e95f2c61fe44f21 Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Thu, 2 Jul 2026 00:49:06 +0100 Subject: [PATCH 6/8] implement P4 repo_map: token-budgeted, seed-aware repository map tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MCP tool repo_map(project, seed_anchors[], token_budget) reading P3's persisted importance scores (spec wire contract frozen for P5). - Two first-class modes (P2 finding): seed-boost (resolved anchors + 1-hop CALLS/USAGE neighbourhood x50) with global importance ranking as the no-seed/unresolvable fallback. Anchor resolution: exact name -> qualified name -> file path. - Weak-seed widen (P2 shape hints): 1-hop symbol neighbourhood <= 3 -> file-of-symbol seeding + 2-hop expansion at x25. - Module/File 1-hop neighbours (file-level co-usage USAGE edges) expand to their file's symbols at x25 — this is how P2's ground-truth co-change neighbourhood (sender.py/gmail_client.py -> extract_address) travels. - Budget: chars/4 estimator (no server-side LLM tokenizer), default 1600 tokens, greedy maximal prefix over the ranked lines; token_budget <= 0 is an input error. - Determinism: effective score DESC, qualified_name ASC (total order). - Score-absence gate (spec AC7): symbol nodes with zero importance coverage -> explicit 'unscored' error advising re-index; partial coverage proceeds (NULLs rank last). - Rendering: 'file: symbol(sig)' one line per symbol; param-only signatures get the name prefixed; whitespace/newlines flattened; no bodies. - Store layer: cbm_store_importance_coverage + cbm_store_top_symbols_by_ importance (json_extract ORDER BY, project-filtered). Verified on real corpus (worktree binary, scratch CBM_CACHE_DIR): budget fit on connectors + codebase-memory-mcp in both modes; zero P3-pinned noise/test-leakage names in top-40; P2 seeded ground truth surfaces 28 sender/gmail lines from rank 3; AC6 net latency 52ms global / 55ms seeded on connectors (bound 500ms). 28/28 AC tests green; suite 5651 PASS with only the pre-existing test_incremental RSS failure. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KK6YFYGwhT69NjYevuEUCr Signed-off-by: Peter Cox --- src/main.c | 6 +- src/mcp/mcp.c | 558 +++++++++++++++++++++++++++++++++++++++++++++- src/store/store.c | 72 ++++++ src/store/store.h | 18 ++ tests/test_mcp.c | 78 +++++++ 5 files changed, 728 insertions(+), 4 deletions(-) diff --git a/src/main.c b/src/main.c index 97adaf15..26befdcc 100644 --- a/src/main.c +++ b/src/main.c @@ -507,9 +507,9 @@ static void print_help(void) { printf(" Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode,\n"); printf(" Antigravity, Aider, KiloCode, Kiro\n"); printf("\nTools: index_repository, search_graph, query_graph, trace_path,\n"); - printf(" get_code_snippet, get_graph_schema, get_architecture, search_code,\n"); - printf(" list_projects, delete_project, index_status, detect_changes,\n"); - printf(" manage_adr, ingest_traces\n"); + printf(" get_code_snippet, get_graph_schema, get_architecture, repo_map,\n"); + printf(" search_code, list_projects, delete_project, index_status,\n"); + printf(" detect_changes, manage_adr, ingest_traces\n"); } /* ── Main ───────────────────────────────────────────────────────── */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 413a9d7a..506d1895 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1,5 +1,5 @@ /* - * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 14 graph tools. + * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 15 graph tools. * * Uses yyjson for fast JSON parsing/building. * Single-threaded event loop: read line → parse → dispatch → respond. @@ -448,6 +448,24 @@ static const tool_def_t TOOLS[] = { "\"description\":\"Aspects to include. 'all' = everything; 'overview' = compact summary " "(all except file_tree); omit = all.\"}},\"required\":[\"project\"]}"}, + {"repo_map", "Repo map", + "Token-budgeted repository map: the most important symbols (persisted importance score, " + "Aider-style weighted degree) rendered one per line as 'file: signature' — no bodies. " + "Two modes: pass seed_anchors (symbol names, qualified names, or file paths relevant to " + "the task) to boost the seed neighbourhood to the top (seed-boost mode, recommended); " + "omit or pass unresolvable seeds for the global importance-ranked map. Output always " + "fits token_budget. Requires an index built by an importance-scoring binary — errors " + "with 'unscored' if the project needs re-indexing.", + "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," + "\"seed_anchors\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}," + "\"description\":\"Task-relevant anchors: exact symbol name, qualified name, or file " + "path. Seed neighbourhoods (1-hop CALLS/USAGE, widened for weakly-connected seeds) are " + "boosted above globally-important symbols. Empty/unresolvable anchors fall back to the " + "global map.\"}," + "\"token_budget\":{\"type\":\"integer\",\"default\":1600,\"description\":\"Approximate " + "output ceiling in tokens (chars/4 estimate). Must be positive.\"}}," + "\"required\":[\"project\"]}"}, + {"search_code", "Search code", "Graph-augmented code search. Finds text patterns via grep, then enriches results with " "the knowledge graph: deduplicates matches into containing functions, ranks by structural " @@ -5578,6 +5596,541 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { return result; } +/* ══════════════════════════════════════════════════════════════════ + * REPO_MAP — token-budgeted, seed-aware repository map (P4) + * + * Reads the persisted per-symbol importance score (pass_importance, P3) + * and renders the top symbols as one 'file: signature' line each, fitted + * to a token budget (chars/4 estimate — no LLM tokenizer exists server- + * side; the budget contract is defined against this estimator). + * + * Two first-class modes (P2 finding: seed-boost mandatory, global is the + * fallback — pai/p2-query-time-validation-finding): + * seeded: resolved seed_anchors and their 1-hop CALLS/USAGE + * neighbourhood are boosted x50; when the whole 1-hop set is + * tiny (<= REPO_MAP_WEAK_NEIGHBOR_THRESHOLD) the walk widens + * with file-of-symbol seeding + 2-hop expansion at x25 + * (P2 shape hints for weakly-connected seeds). + * global: no/unresolvable seeds -> pure importance ranking. + * + * Determinism: effective score DESC, qualified_name ASC (total order). + * Score-absence gate (spec AC7): a project whose symbol nodes carry no + * importance key was indexed by a pre-P3 binary -> explicit 'unscored' + * error, never a silently unranked map. + * ══════════════════════════════════════════════════════════════════ */ + +enum { + REPO_MAP_DEFAULT_BUDGET = 1600, /* spec: default tunable ~1-2k tokens */ + REPO_MAP_MAX_SEEDS = 50, /* anchors beyond this are dropped */ + REPO_MAP_NODES_PER_ANCHOR = 16, /* multi-resolving anchor cap */ + REPO_MAP_WEAK_NEIGHBOR_THRESHOLD = 3, + REPO_MAP_MAX_BOOSTED = 512, /* total boosted-node ceiling */ + REPO_MAP_MIN_CANDIDATES = 200, + REPO_MAP_MAX_CANDIDATES = 10000, + REPO_MAP_TOKEN_CHARS = 4, /* chars-per-token estimate divisor */ +}; +static const double REPO_MAP_SEED_BOOST = 50.0; /* P2's validated multiplier */ +static const double REPO_MAP_WIDEN_BOOST = 25.0; /* weak-seed widened set */ + +typedef struct { + int64_t id; + double boost; +} rm_boost_t; + +typedef struct { + rm_boost_t items[REPO_MAP_MAX_BOOSTED]; + int count; +} rm_boost_list_t; + +/* Add (or raise) a node's boost. Never downgrades an existing boost. */ +static void rm_boost_add(rm_boost_list_t *bl, int64_t id, double boost) { + for (int i = 0; i < bl->count; i++) { + if (bl->items[i].id == id) { + if (boost > bl->items[i].boost) { + bl->items[i].boost = boost; + } + return; + } + } + if (bl->count < REPO_MAP_MAX_BOOSTED) { + bl->items[bl->count].id = id; + bl->items[bl->count].boost = boost; + bl->count++; + } +} + +static double rm_boost_get(const rm_boost_list_t *bl, int64_t id) { + for (int i = 0; i < bl->count; i++) { + if (bl->items[i].id == id) { + return bl->items[i].boost; + } + } + return 1.0; +} + +/* Append id to a fixed array if absent. Returns true if present after call. */ +static bool rm_id_add(int64_t *arr, int *count, int cap, int64_t id) { + for (int i = 0; i < *count; i++) { + if (arr[i] == id) { + return true; + } + } + if (*count < cap) { + arr[(*count)++] = id; + return true; + } + return false; +} + +static bool rm_id_contains(const int64_t *arr, int count, int64_t id) { + for (int i = 0; i < count; i++) { + if (arr[i] == id) { + return true; + } + } + return false; +} + +/* Collect the 1-hop CALLS|USAGE neighbour ids of `id` (both directions) + * into out (deduped, capped). */ +static void rm_collect_neighbors(cbm_store_t *store, int64_t id, int64_t *out, int *count, + int cap) { + cbm_edge_t *edges = NULL; + int ecount = 0; + if (cbm_store_find_edges_by_source(store, id, &edges, &ecount) == CBM_STORE_OK) { + for (int i = 0; i < ecount; i++) { + if (edges[i].type && + (strcmp(edges[i].type, "CALLS") == 0 || strcmp(edges[i].type, "USAGE") == 0)) { + rm_id_add(out, count, cap, edges[i].target_id); + } + } + cbm_store_free_edges(edges, ecount); + } + edges = NULL; + ecount = 0; + if (cbm_store_find_edges_by_target(store, id, &edges, &ecount) == CBM_STORE_OK) { + for (int i = 0; i < ecount; i++) { + if (edges[i].type && + (strcmp(edges[i].type, "CALLS") == 0 || strcmp(edges[i].type, "USAGE") == 0)) { + rm_id_add(out, count, cap, edges[i].source_id); + } + } + cbm_store_free_edges(edges, ecount); + } +} + +/* Collapse whitespace runs (incl. newlines) to single spaces, in place. + * Multi-line signatures otherwise break the one-line-per-symbol map shape + * (observed on the real connectors corpus: black-formatted Python defs). */ +static void rm_flatten_ws(char *s) { + char *r = s; + char *w = s; + bool in_ws = false; + while (*r) { + char c = *r; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + in_ws = true; + } else { + if (in_ws && w != s) { + *w++ = ' '; + } + in_ws = false; + *w++ = c; + } + r++; + } + *w = '\0'; +} + +/* Parse importance (default 0.0) and signature (heap copy, whitespace- + * flattened, or NULL) from a node's properties_json. */ +static void rm_parse_props(const char *props, double *out_importance, char **out_sig) { + *out_importance = 0.0; + *out_sig = NULL; + if (!props || !props[0]) { + return; + } + yyjson_doc *doc = yyjson_read(props, strlen(props), 0); + if (!doc) { + return; + } + yyjson_val *root = yyjson_doc_get_root(doc); + if (yyjson_is_obj(root)) { + yyjson_val *imp = yyjson_obj_get(root, "importance"); + if (yyjson_is_num(imp)) { + *out_importance = yyjson_get_num(imp); + } + yyjson_val *sig = yyjson_obj_get(root, "signature"); + if (yyjson_is_str(sig)) { + *out_sig = heap_strdup(yyjson_get_str(sig)); + if (*out_sig) { + rm_flatten_ws(*out_sig); + } + } + } + yyjson_doc_free(doc); +} + +/* A ranked, rendered map candidate. */ +typedef struct { + cbm_node_t node; /* owned */ + double eff; /* importance x boost */ + char *line; /* owned: "file: signature\n" */ + size_t line_len; +} rm_cand_t; + +static int rm_cand_cmp(const void *pa, const void *pb) { + const rm_cand_t *a = (const rm_cand_t *)pa; + const rm_cand_t *b = (const rm_cand_t *)pb; + if (a->eff > b->eff) { + return -1; + } + if (a->eff < b->eff) { + return 1; + } + /* qualified_name ASC tie-break: total order (QNs unique per project). */ + const char *qa = a->node.qualified_name ? a->node.qualified_name : ""; + const char *qb = b->node.qualified_name ? b->node.qualified_name : ""; + return strcmp(qa, qb); +} + +/* Resolved seed record (id + file path for the file-of-symbol widen). */ +typedef struct { + int64_t id; + char *file_path; /* owned */ +} rm_seed_t; + +/* Resolve one anchor: exact name -> exact qualified_name -> exact file path. + * Appends up to REPO_MAP_NODES_PER_ANCHOR seed records + x50 boosts. + * Returns true if the anchor resolved to at least one node. */ +static bool rm_resolve_anchor(cbm_store_t *store, const char *project, const char *anchor, + rm_boost_list_t *boosts, rm_seed_t *seeds, int *seed_count, + int seed_cap) { + cbm_node_t *nodes = NULL; + int count = 0; + cbm_store_find_nodes_by_name(store, project, anchor, &nodes, &count); + if (count == 0) { + cbm_store_free_nodes(nodes, count); + nodes = NULL; + cbm_node_t one = {0}; + if (cbm_store_find_node_by_qn(store, project, anchor, &one) == CBM_STORE_OK) { + nodes = malloc(sizeof(cbm_node_t)); + nodes[0] = one; /* ownership moves into the array */ + count = 1; + } + } + if (count == 0) { + cbm_store_free_nodes(nodes, count); + nodes = NULL; + cbm_store_find_nodes_by_file(store, project, anchor, &nodes, &count); + } + if (count == 0) { + cbm_store_free_nodes(nodes, count); + return false; + } + int take = count < REPO_MAP_NODES_PER_ANCHOR ? count : REPO_MAP_NODES_PER_ANCHOR; + for (int i = 0; i < take; i++) { + rm_boost_add(boosts, nodes[i].id, REPO_MAP_SEED_BOOST); + if (*seed_count < seed_cap) { + seeds[*seed_count].id = nodes[i].id; + seeds[*seed_count].file_path = + nodes[i].file_path ? heap_strdup(nodes[i].file_path) : NULL; + (*seed_count)++; + } + } + cbm_store_free_nodes(nodes, count); + return true; +} + +static char *handle_repo_map(cbm_mcp_server_t *srv, const char *args) { + char *project = cbm_mcp_get_string_arg(args, "project"); + cbm_store_t *store = resolve_store(srv, project); + REQUIRE_STORE(store, project); + + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(project); + return not_indexed; + } + + int budget = cbm_mcp_get_int_arg(args, "token_budget", REPO_MAP_DEFAULT_BUDGET); + if (budget <= 0) { + free(project); + return cbm_mcp_text_result("token_budget must be a positive integer", true); + } + + /* Score-absence gate (spec AC7): symbols exist but none carries an + * importance score -> the index predates pass_importance. Refuse with + * an explicit signal rather than emit a silently unranked map. */ + int scored = 0; + int total_symbols = 0; + if (cbm_store_importance_coverage(store, project, &scored, &total_symbols) != CBM_STORE_OK) { + free(project); + return cbm_mcp_text_result("importance coverage query failed", true); + } + if (total_symbols > 0 && scored == 0) { + char msg[CBM_SZ_1K]; + snprintf(msg, sizeof(msg), + "project '%s' is unscored: its index has no persisted importance scores " + "(indexed by a pre-importance binary). Re-run index_repository(" + "repo_path=..., mode='full') with a current binary, then retry repo_map.", + project); + free(project); + return cbm_mcp_text_result(msg, true); + } + + /* Parse seed_anchors: array of strings. Documented leniency (AC7 + * graceful-input): a bare string is coerced to a one-element list, + * non-string elements are skipped, the list is capped at + * REPO_MAP_MAX_SEEDS. Anything else counts as no seeds. */ + char *anchors[REPO_MAP_MAX_SEEDS]; + int anchor_count = 0; + int seeds_requested = 0; + { + yyjson_doc *adoc = yyjson_read(args, strlen(args), 0); + if (adoc) { + yyjson_val *sa = yyjson_obj_get(yyjson_doc_get_root(adoc), "seed_anchors"); + if (yyjson_is_str(sa)) { + seeds_requested = 1; + anchors[anchor_count++] = heap_strdup(yyjson_get_str(sa)); + } else if (yyjson_is_arr(sa)) { + seeds_requested = (int)yyjson_arr_size(sa); + size_t idx; + size_t max; + yyjson_val *elem; + yyjson_arr_foreach(sa, idx, max, elem) { + if (yyjson_is_str(elem) && anchor_count < REPO_MAP_MAX_SEEDS) { + anchors[anchor_count++] = heap_strdup(yyjson_get_str(elem)); + } + } + } + yyjson_doc_free(adoc); + } + } + + /* Resolve seeds -> x50 boosts + seed records. */ + rm_boost_list_t *boosts = calloc(CBM_ALLOC_ONE, sizeof(*boosts)); + rm_seed_t seeds[REPO_MAP_MAX_BOOSTED]; + int seed_count = 0; + int seeds_resolved = 0; + for (int i = 0; i < anchor_count; i++) { + if (rm_resolve_anchor(store, project, anchors[i], boosts, seeds, &seed_count, + REPO_MAP_MAX_BOOSTED)) { + seeds_resolved++; + } + free(anchors[i]); + } + + /* 1-hop CALLS|USAGE neighbourhood of all seeds -> x50. */ + int64_t onehop[REPO_MAP_MAX_BOOSTED]; + int onehop_count = 0; + for (int i = 0; i < seed_count; i++) { + rm_collect_neighbors(store, seeds[i].id, onehop, &onehop_count, REPO_MAP_MAX_BOOSTED); + } + /* Boost the 1-hop set. A Module/File neighbour (file-level USAGE edge — + * "this file uses the seed") is not renderable itself, but its file's + * symbols ARE the co-change neighbourhood P2's ground truth names + * (sender.py/gmail_client.py members reach extract_address exactly this + * way) — expand it to those symbols at the widen tier. Only SYMBOL + * neighbours count toward the weak-seed threshold. */ + int fresh_onehop = 0; + for (int i = 0; i < onehop_count; i++) { + bool is_seed = false; + for (int j = 0; j < seed_count; j++) { + if (seeds[j].id == onehop[i]) { + is_seed = true; + break; + } + } + rm_boost_add(boosts, onehop[i], REPO_MAP_SEED_BOOST); + + cbm_node_t nb = {0}; + if (cbm_store_find_node_by_id(store, onehop[i], &nb) != CBM_STORE_OK) { + continue; + } + bool is_module = nb.label && (strcmp(nb.label, "Module") == 0 || + strcmp(nb.label, "File") == 0); + if (is_module && nb.file_path && nb.project && strcmp(nb.project, project) == 0) { + cbm_node_t *fnodes = NULL; + int fcount = 0; + cbm_store_find_nodes_by_file(store, project, nb.file_path, &fnodes, &fcount); + for (int j = 0; j < fcount; j++) { + rm_boost_add(boosts, fnodes[j].id, REPO_MAP_WIDEN_BOOST); + } + cbm_store_free_nodes(fnodes, fcount); + } else if (!is_module && !is_seed) { + fresh_onehop++; + } + cbm_node_free_fields(&nb); + } + + /* Weak-seed widen (P2 shape hints): a tiny 1-hop neighbourhood means the + * seed alone under-boosts (P2 task A). Widen with (a) file-of-symbol + * seeding and (b) 2-hop expansion, both at x25. */ + if (seed_count > 0 && fresh_onehop <= REPO_MAP_WEAK_NEIGHBOR_THRESHOLD) { + for (int i = 0; i < seed_count; i++) { + if (!seeds[i].file_path) { + continue; + } + cbm_node_t *fnodes = NULL; + int fcount = 0; + cbm_store_find_nodes_by_file(store, project, seeds[i].file_path, &fnodes, &fcount); + for (int j = 0; j < fcount; j++) { + rm_boost_add(boosts, fnodes[j].id, REPO_MAP_WIDEN_BOOST); + } + cbm_store_free_nodes(fnodes, fcount); + } + int64_t twohop[REPO_MAP_MAX_BOOSTED]; + int twohop_count = 0; + for (int i = 0; i < onehop_count; i++) { + rm_collect_neighbors(store, onehop[i], twohop, &twohop_count, REPO_MAP_MAX_BOOSTED); + } + for (int i = 0; i < twohop_count; i++) { + rm_boost_add(boosts, twohop[i], REPO_MAP_WIDEN_BOOST); + } + } + for (int i = 0; i < seed_count; i++) { + free(seeds[i].file_path); + } + + /* Candidate pool: top-N global by persisted importance (N adaptive from + * the budget: ~10 tokens/line means budget/2 is a generous line ceiling) + * + every boosted node not already in the pool. */ + int limit = budget / 2; + if (limit < REPO_MAP_MIN_CANDIDATES) { + limit = REPO_MAP_MIN_CANDIDATES; + } + if (limit > REPO_MAP_MAX_CANDIDATES) { + limit = REPO_MAP_MAX_CANDIDATES; + } + cbm_node_t *top = NULL; + int top_count = 0; + if (cbm_store_top_symbols_by_importance(store, project, limit, &top, &top_count) != + CBM_STORE_OK) { + free(boosts); + free(project); + return cbm_mcp_text_result("importance ranking query failed", true); + } + + int cand_cap = top_count + boosts->count; + if (cand_cap == 0) { + cand_cap = 1; + } + rm_cand_t *cands = calloc((size_t)cand_cap, sizeof(rm_cand_t)); + int cand_count = 0; + int64_t seen[REPO_MAP_MAX_CANDIDATES + REPO_MAP_MAX_BOOSTED]; + int seen_count = 0; + for (int i = 0; i < top_count; i++) { + cands[cand_count].node = top[i]; /* ownership moves */ + rm_id_add(seen, &seen_count, (int)(sizeof(seen) / sizeof(seen[0])), top[i].id); + cand_count++; + } + free(top); /* rows moved into cands; free only the array shell */ + + /* Boosted nodes outside the top-N pool (project-checked: edges could in + * principle cross rows in a shared test store — never leak them). */ + for (int i = 0; i < boosts->count; i++) { + if (rm_id_contains(seen, seen_count, boosts->items[i].id)) { + continue; + } + cbm_node_t extra = {0}; + if (cbm_store_find_node_by_id(store, boosts->items[i].id, &extra) != CBM_STORE_OK) { + continue; + } + bool symbol_label = extra.label && (strcmp(extra.label, "Function") == 0 || + strcmp(extra.label, "Method") == 0 || + strcmp(extra.label, "Class") == 0); + if (!symbol_label || !extra.project || strcmp(extra.project, project) != 0) { + cbm_node_free_fields(&extra); + continue; + } + cands[cand_count].node = extra; + rm_id_add(seen, &seen_count, (int)(sizeof(seen) / sizeof(seen[0])), extra.id); + cand_count++; + } + + /* Score + render each candidate. */ + for (int i = 0; i < cand_count; i++) { + double importance = 0.0; + char *sig = NULL; + rm_parse_props(cands[i].node.properties_json, &importance, &sig); + cands[i].eff = importance * rm_boost_get(boosts, cands[i].node.id); + const char *fp = cands[i].node.file_path ? cands[i].node.file_path : ""; + const char *nm = cands[i].node.name ? cands[i].node.name : ""; + const char *shown = sig ? sig : nm; + /* Several grammars persist only the parameter list as the signature + * ("(self, x)") — prefix the symbol name so every line reads + * 'file: symbol(sig)' (spec shape; observed on the real connectors + * corpus where bare "(self)" lines carried no information). */ + const char *prefix = (sig && sig[0] == '(') ? nm : ""; + size_t need = strlen(fp) + strlen(prefix) + strlen(shown) + MCP_COL_4; + cands[i].line = malloc(need); /* ": " + "\n" + NUL */ + if (cands[i].line) { + int w = snprintf(cands[i].line, need, "%s: %s%s\n", fp, prefix, shown); + cands[i].line_len = w > 0 ? (size_t)w : 0; + } + free(sig); + } + free(boosts); + + qsort(cands, (size_t)cand_count, sizeof(rm_cand_t), rm_cand_cmp); + + /* Budget fit: greedy prefix accumulation over the ranked lines (monotone + * — the maximal prefix under the ceiling; estimator = ceil(chars/4)). */ + size_t cum_chars = 0; + int included = 0; + for (int i = 0; i < cand_count; i++) { + size_t next = cum_chars + cands[i].line_len; + if ((next + REPO_MAP_TOKEN_CHARS - 1) / REPO_MAP_TOKEN_CHARS > (size_t)budget) { + break; + } + cum_chars = next; + included = i + 1; + } + + char *map = malloc(cum_chars + 1); + size_t pos = 0; + for (int i = 0; i < included; i++) { + if (cands[i].line && map) { + memcpy(map + pos, cands[i].line, cands[i].line_len); + pos += cands[i].line_len; + } + } + if (map) { + map[pos] = '\0'; + } + + int estimated_tokens = (int)((cum_chars + REPO_MAP_TOKEN_CHARS - 1) / REPO_MAP_TOKEN_CHARS); + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "project", project); + yyjson_mut_obj_add_str(doc, root, "mode", seeds_resolved > 0 ? "seeded" : "global"); + yyjson_mut_obj_add_int(doc, root, "budget", budget); + yyjson_mut_obj_add_int(doc, root, "estimated_tokens", estimated_tokens); + yyjson_mut_obj_add_int(doc, root, "symbol_count", included); + yyjson_mut_obj_add_int(doc, root, "total_symbols", total_symbols); + yyjson_mut_obj_add_int(doc, root, "seed_anchors_requested", seeds_requested); + yyjson_mut_obj_add_int(doc, root, "seed_anchors_resolved", seeds_resolved); + yyjson_mut_obj_add_str(doc, root, "map", map ? map : ""); + + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + + for (int i = 0; i < cand_count; i++) { + cbm_node_free_fields(&cands[i].node); + free(cands[i].line); + } + free(cands); + free(map); + free(project); + + char *result = cbm_mcp_text_result(json, false); + free(json); + return result; +} + /* ── Tool dispatch ────────────────────────────────────────────── */ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { @@ -5609,6 +6162,9 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch if (strcmp(tool_name, "get_architecture") == 0) { return handle_get_architecture(srv, args_json); } + if (strcmp(tool_name, "repo_map") == 0) { + return handle_repo_map(srv, args_json); + } /* Pipeline-dependent tools */ if (strcmp(tool_name, "index_repository") == 0) { diff --git a/src/store/store.c b/src/store/store.c index 33a9453c..96c5b33a 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -6065,6 +6065,78 @@ int cbm_store_find_architecture_docs(cbm_store_t *s, const char *project, char * return CBM_STORE_OK; } +/* ── Importance (repo_map) ──────────────────────────────────────── */ + +int cbm_store_importance_coverage(cbm_store_t *s, const char *project, int *out_scored, + int *out_total) { + *out_scored = 0; + *out_total = 0; + if (!s || !s->db) { + return CBM_STORE_ERR; + } + const char *sql = + "SELECT COUNT(*), " + "SUM(CASE WHEN json_extract(properties, '$.importance') IS NOT NULL THEN 1 ELSE 0 END) " + "FROM nodes WHERE project=?1 AND label IN ('Function','Method','Class')"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "importance_coverage"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + int rc = CBM_STORE_ERR; + if (sqlite3_step(stmt) == SQLITE_ROW) { + *out_total = sqlite3_column_int(stmt, 0); + *out_scored = sqlite3_column_int(stmt, SKIP_ONE); /* SUM over 0 rows is NULL -> 0 */ + rc = CBM_STORE_OK; + } else { + store_set_error_sqlite(s, "importance_coverage_step"); + } + sqlite3_finalize(stmt); + return rc; +} + +int cbm_store_top_symbols_by_importance(cbm_store_t *s, const char *project, int limit, + cbm_node_t **out, int *count) { + *out = NULL; + *count = 0; + if (!s || !s->db || limit <= 0) { + return CBM_STORE_ERR; + } + /* NULL importance sorts LAST under DESC (SQLite: NULL < everything), so a + * partially-scored project ranks its unscored nodes at the bottom. + * qualified_name ASC tie-break makes the order total (unique per project) + * — repo_map's determinism contract. */ + const char *sql = "SELECT id, project, label, name, qualified_name, file_path, " + "start_line, end_line, properties FROM nodes " + "WHERE project=?1 AND label IN ('Function','Method','Class') " + "ORDER BY json_extract(properties, '$.importance') DESC, " + "qualified_name ASC LIMIT ?2"; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "top_symbols_by_importance"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + sqlite3_bind_int(stmt, ST_COL_2, limit); + + int cap = ST_INIT_CAP_16; + int n = 0; + cbm_node_t *arr = malloc(cap * sizeof(cbm_node_t)); + while (sqlite3_step(stmt) == SQLITE_ROW) { + if (n >= cap) { + cap *= ST_GROWTH; + arr = safe_realloc(arr, cap * sizeof(cbm_node_t)); + } + scan_node(stmt, &arr[n]); + n++; + } + sqlite3_finalize(stmt); + *out = arr; + *count = n; + return CBM_STORE_OK; +} + /* ── Memory management ──────────────────────────────────────────── */ void cbm_node_free_fields(cbm_node_t *n) { diff --git a/src/store/store.h b/src/store/store.h index a4541e2d..7dbafda5 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -645,6 +645,24 @@ int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *e int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges, int edge_count, cbm_louvain_result_t **out, int *out_count); +/* ── Importance (repo_map) ──────────────────────────────────────── */ + +/* Count symbol nodes (Function/Method/Class) and how many of them carry a + * persisted numeric "importance" key in properties (pass_importance, P3). + * Used by repo_map's score-absence gate: total>0 && scored==0 means the + * project was indexed by a pre-importance binary and must be re-indexed. + * Returns CBM_STORE_OK or CBM_STORE_ERR. */ +int cbm_store_importance_coverage(cbm_store_t *s, const char *project, int *out_scored, + int *out_total); + +/* Top symbol nodes (Function/Method/Class) ordered by persisted importance + * DESC with deterministic qualified_name ASC tie-break. Nodes without an + * importance key sort last (SQLite NULLs-last under DESC). Returns an + * allocated array (caller frees with cbm_store_free_nodes). + * Returns CBM_STORE_OK or CBM_STORE_ERR. */ +int cbm_store_top_symbols_by_importance(cbm_store_t *s, const char *project, int limit, + cbm_node_t **out, int *count); + /* ── Memory management helpers ──────────────────────────────────── */ /* Free heap-allocated strings in a stack-allocated node (does NOT free the node itself). */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 823ce9f9..9b46e04b 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5066,6 +5066,44 @@ TEST(repo_map_weak_seed_triggers_widen_walk) { PASS(); } +TEST(repo_map_module_usage_neighbor_expands_to_its_file_symbols) { + /* Real-corpus refinement (row 3/4 sharpening, added with the + * implementation): file-level co-usage arrives as a Module node with a + * USAGE edge to the seed (P2 ground truth: sender.py/gmail_client.py + * reach extract_address exactly this way). A Module 1-hop neighbour is + * not renderable itself but must expand to its file's symbols at the + * widen tier — they are the co-change neighbourhood. */ + cbm_mcp_server_t *srv = rm_setup_server("rm-module-nb"); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *project = "rm-module-nb"; + + int64_t s2 = rm_add_node(st, project, "Function", "S2", "rm-module-nb.S2", "pkg/s.go", 5.0, + "S2() error"); + int64_t mod = rm_add_node_no_score(st, project, "Module", "pkg/user.py", "rm-module-nb.mod", + "pkg/user.py"); + rm_add_node(st, project, "Function", "UserHelper", "rm-module-nb.UserHelper", "pkg/user.py", + 1.0, "UserHelper() int"); + /* Raw importance ABOVE the widened symbol's raw (1.0) but below its + * boosted score (1x25): proves the expansion boost does the ranking. */ + rm_add_node(st, project, "Function", "BigDeal", "rm-module-nb.BigDeal", "pkg/big.go", 20.0, + "BigDeal() error"); + rm_add_edge(st, project, mod, s2, "USAGE"); + + char *text = rm_call(srv, "{\"project\":\"rm-module-nb\",\"seed_anchors\":[\"S2\"]," + "\"token_budget\":100000}"); + ASSERT_NOT_NULL(text); + const char *helper_pos = strstr(text, "UserHelper() int"); + const char *big_pos = strstr(text, "BigDeal() error"); + ASSERT_NOT_NULL(helper_pos); + ASSERT_NOT_NULL(big_pos); + ASSERT_TRUE(helper_pos < big_pos); + /* The Module node itself never renders as a map line. */ + ASSERT_NULL(strstr(text, "pkg/user.py: pkg/user.py")); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + /* ── Row 5: AC2c empty/unusable seeds → global map ───────────────── */ TEST(repo_map_no_seed_and_empty_seed_and_all_unresolvable_yield_identical_global_map) { @@ -5302,6 +5340,44 @@ TEST(repo_map_renders_signature_level_no_body_leak) { PASS(); } +TEST(repo_map_param_only_signature_gets_name_prefix_and_ws_flatten) { + /* Real-corpus refinement (row 10 sharpening, added with the + * implementation): several grammars persist only the parameter list as + * the signature ("(self)"), and black-formatted defs embed newlines. + * The renderer must prefix the symbol name and flatten whitespace so + * every line reads 'file: symbol(sig)' on ONE line. */ + cbm_mcp_server_t *srv = rm_setup_server("rm-render-prefix"); + cbm_store_t *st = cbm_mcp_server_store(srv); + + cbm_node_t n = {0}; + n.project = "rm-render-prefix"; + n.label = "Method"; + n.name = "method_a"; + n.qualified_name = "rm-render-prefix.M.method_a"; + n.file_path = "pkg/m.py"; + n.start_line = 1; + n.end_line = 4; + /* JSON-escaped newlines inside the signature value. */ + n.properties_json = + "{\"importance\":10.0,\"signature\":\"(\\n self,\\n x: int\\n)\"}"; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + + char *text = rm_call(srv, "{\"project\":\"rm-render-prefix\"}"); + ASSERT_NOT_NULL(text); + char *map = rm_json_str(text, "map"); + ASSERT_NOT_NULL(map); + ASSERT_NOT_NULL(strstr(map, "pkg/m.py: method_a( self, x: int )\n")); + /* Exactly one line: the first newline in the map is its last character + * — no embedded newline from the multi-line signature survives. */ + const char *first_nl = strchr(map, '\n'); + ASSERT_NOT_NULL(first_nl); + ASSERT_EQ((int)(strlen(map) - (size_t)(first_nl - map)), 1); + free(map); + free(text); + cbm_mcp_server_free(srv); + PASS(); +} + /* ── Row 11: determinism ──────────────────────────────────────────── */ TEST(repo_map_deterministic_byte_identical_across_calls) { @@ -5573,6 +5649,7 @@ SUITE(mcp) { RUN_TEST(repo_map_seed_by_file_path); RUN_TEST(repo_map_seed_resolves_multiple_nodes); RUN_TEST(repo_map_weak_seed_triggers_widen_walk); + RUN_TEST(repo_map_module_usage_neighbor_expands_to_its_file_symbols); RUN_TEST(repo_map_no_seed_and_empty_seed_and_all_unresolvable_yield_identical_global_map); RUN_TEST(repo_map_mixed_resolvable_and_unresolvable_seeds_uses_seeded_mode); RUN_TEST(repo_map_unscored_project_returns_explicit_gate_error); @@ -5584,6 +5661,7 @@ SUITE(mcp) { RUN_TEST(repo_map_malformed_seed_anchors_non_string_elements_skipped); RUN_TEST(repo_map_absurdly_long_seed_list_capped_no_hang); RUN_TEST(repo_map_renders_signature_level_no_body_leak); + RUN_TEST(repo_map_param_only_signature_gets_name_prefix_and_ws_flatten); RUN_TEST(repo_map_deterministic_byte_identical_across_calls); RUN_TEST(repo_map_no_cross_project_leakage); RUN_TEST(repo_map_repeated_calls_stable_no_leak_surface); From faa24188566af235993bf6dd0055a2b44bcb373f Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Sun, 5 Jul 2026 11:44:19 +0100 Subject: [PATCH 7/8] fix(mcp): null-guard malloc/calloc allocations in repo_map Signed-off-by: Peter Cox --- src/mcp/mcp.c | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 506d1895..e4f09380 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -5815,8 +5815,12 @@ static bool rm_resolve_anchor(cbm_store_t *store, const char *project, const cha cbm_node_t one = {0}; if (cbm_store_find_node_by_qn(store, project, anchor, &one) == CBM_STORE_OK) { nodes = malloc(sizeof(cbm_node_t)); - nodes[0] = one; /* ownership moves into the array */ - count = 1; + if (nodes) { + nodes[0] = one; /* ownership moves into the array */ + count = 1; + } else { + cbm_node_free_fields(&one); + } } } if (count == 0) { @@ -5910,6 +5914,13 @@ static char *handle_repo_map(cbm_mcp_server_t *srv, const char *args) { /* Resolve seeds -> x50 boosts + seed records. */ rm_boost_list_t *boosts = calloc(CBM_ALLOC_ONE, sizeof(*boosts)); + if (!boosts) { + for (int i = 0; i < anchor_count; i++) { + free(anchors[i]); + } + free(project); + return cbm_mcp_text_result("repo_map: allocation failed", true); + } rm_seed_t seeds[REPO_MAP_MAX_BOOSTED]; int seed_count = 0; int seeds_resolved = 0; From 5cc082b4cc957b3bffd2287bec0babe04302f8e7 Mon Sep 17 00:00:00 2001 From: Peter Cox Date: Sun, 5 Jul 2026 11:44:20 +0100 Subject: [PATCH 8/8] style: apply clang-format-20 to repo_map Signed-off-by: Peter Cox --- src/mcp/mcp.c | 4 +- tests/test_mcp.c | 166 ++++++++++++++++++++++++++++------------------- 2 files changed, 100 insertions(+), 70 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e4f09380..2f6a6dd9 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -5959,8 +5959,8 @@ static char *handle_repo_map(cbm_mcp_server_t *srv, const char *args) { if (cbm_store_find_node_by_id(store, onehop[i], &nb) != CBM_STORE_OK) { continue; } - bool is_module = nb.label && (strcmp(nb.label, "Module") == 0 || - strcmp(nb.label, "File") == 0); + bool is_module = + nb.label && (strcmp(nb.label, "Module") == 0 || strcmp(nb.label, "File") == 0); if (is_module && nb.file_path && nb.project && strcmp(nb.project, project) == 0) { cbm_node_t *fnodes = NULL; int fcount = 0; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 9b46e04b..ba898b0a 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -409,9 +409,9 @@ TEST(mcp_get_architecture_aspects_schema_enum_pr560) { ASSERT_TRUE(yyjson_is_arr(enum_arr)); /* The enum must be exactly the valid-token set — no more, no less. */ - static const char *expected[] = {"all", "overview", "structure", "dependencies", - "routes", "languages", "packages", "entry_points", - "hotspots", "boundaries", "layers", "file_tree", + static const char *expected[] = {"all", "overview", "structure", "dependencies", + "routes", "languages", "packages", "entry_points", + "hotspots", "boundaries", "layers", "file_tree", "clusters"}; size_t expected_count = sizeof(expected) / sizeof(expected[0]); ASSERT_EQ(yyjson_arr_size(enum_arr), expected_count); @@ -1015,18 +1015,34 @@ TEST(tool_trace_call_path_distinct_defs_not_over_unioned) { cbm_mcp_server_set_project(srv, proj); cbm_store_upsert_project(st, proj, "/tmp/ou"); /* two unrelated real definitions of "dupreal", DIFFERENT body spans */ - cbm_node_t da = {.project = proj, .label = "Function", .name = "dupreal", - .qualified_name = "ou-proj.a.dupreal", .file_path = "a.c", - .start_line = 10, .end_line = 20}; /* span 10 */ - cbm_node_t db = {.project = proj, .label = "Function", .name = "dupreal", - .qualified_name = "ou-proj.b.dupreal", .file_path = "b.c", - .start_line = 10, .end_line = 40}; /* span 30 (no tie) */ - cbm_node_t ca = {.project = proj, .label = "Function", .name = "callerA", - .qualified_name = "ou-proj.a.callerA", .file_path = "a.c", - .start_line = 30, .end_line = 40}; - cbm_node_t cb = {.project = proj, .label = "Function", .name = "callerB", - .qualified_name = "ou-proj.b.callerB", .file_path = "b.c", - .start_line = 50, .end_line = 60}; + cbm_node_t da = {.project = proj, + .label = "Function", + .name = "dupreal", + .qualified_name = "ou-proj.a.dupreal", + .file_path = "a.c", + .start_line = 10, + .end_line = 20}; /* span 10 */ + cbm_node_t db = {.project = proj, + .label = "Function", + .name = "dupreal", + .qualified_name = "ou-proj.b.dupreal", + .file_path = "b.c", + .start_line = 10, + .end_line = 40}; /* span 30 (no tie) */ + cbm_node_t ca = {.project = proj, + .label = "Function", + .name = "callerA", + .qualified_name = "ou-proj.a.callerA", + .file_path = "a.c", + .start_line = 30, + .end_line = 40}; + cbm_node_t cb = {.project = proj, + .label = "Function", + .name = "callerB", + .qualified_name = "ou-proj.b.callerB", + .file_path = "b.c", + .start_line = 50, + .end_line = 60}; int64_t id_da = cbm_store_upsert_node(st, &da); int64_t id_db = cbm_store_upsert_node(st, &db); int64_t id_ca = cbm_store_upsert_node(st, &ca); @@ -1041,9 +1057,10 @@ TEST(tool_trace_call_path_distinct_defs_not_over_unioned) { cbm_store_insert_edge(st, &eb); char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dupreal\"," - "\"project\":\"ou-proj\",\"direction\":\"inbound\"}}}"); + srv, + "{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dupreal\"," + "\"project\":\"ou-proj\",\"direction\":\"inbound\"}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); @@ -1067,18 +1084,34 @@ TEST(tool_trace_call_path_dts_stub_unions_with_impl) { const char *proj = "dts-proj"; cbm_mcp_server_set_project(srv, proj); cbm_store_upsert_project(st, proj, "/tmp/dts"); - cbm_node_t impl = {.project = proj, .label = "Function", .name = "sym546", - .qualified_name = "dts-proj.impl.sym546", .file_path = "src/sym.ts", - .start_line = 10, .end_line = 30}; /* real body */ - cbm_node_t stub = {.project = proj, .label = "Function", .name = "sym546", - .qualified_name = "dts-proj.stub.sym546", .file_path = "types/sym.d.ts", - .start_line = 5, .end_line = 5}; /* body-less ambient decl */ - cbm_node_t crel = {.project = proj, .label = "Function", .name = "callerRel", - .qualified_name = "dts-proj.callerRel", .file_path = "src/rel.ts", - .start_line = 1, .end_line = 8}; - cbm_node_t cali = {.project = proj, .label = "Function", .name = "callerAlias", - .qualified_name = "dts-proj.callerAlias", .file_path = "src/ali.ts", - .start_line = 1, .end_line = 8}; + cbm_node_t impl = {.project = proj, + .label = "Function", + .name = "sym546", + .qualified_name = "dts-proj.impl.sym546", + .file_path = "src/sym.ts", + .start_line = 10, + .end_line = 30}; /* real body */ + cbm_node_t stub = {.project = proj, + .label = "Function", + .name = "sym546", + .qualified_name = "dts-proj.stub.sym546", + .file_path = "types/sym.d.ts", + .start_line = 5, + .end_line = 5}; /* body-less ambient decl */ + cbm_node_t crel = {.project = proj, + .label = "Function", + .name = "callerRel", + .qualified_name = "dts-proj.callerRel", + .file_path = "src/rel.ts", + .start_line = 1, + .end_line = 8}; + cbm_node_t cali = {.project = proj, + .label = "Function", + .name = "callerAlias", + .qualified_name = "dts-proj.callerAlias", + .file_path = "src/ali.ts", + .start_line = 1, + .end_line = 8}; int64_t id_impl = cbm_store_upsert_node(st, &impl); int64_t id_stub = cbm_store_upsert_node(st, &stub); int64_t id_crel = cbm_store_upsert_node(st, &crel); @@ -1670,7 +1703,8 @@ TEST(search_code_scoped_path_with_spaces_issue687) { * a project with two indexed files that both contain the search pattern — * src/handler.go (inside the filter) and vendor/other.go (outside it). */ static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path, - size_t src_sz, char *vendor_path, size_t vendor_sz) { + size_t src_sz, char *vendor_path, + size_t vendor_sz) { snprintf(tmp, tmp_sz, "/tmp/cbm_srch_pref_XXXXXX"); if (!cbm_mkdtemp(tmp)) { return NULL; @@ -2212,9 +2246,8 @@ TEST(tool_manage_adr_not_found_rich_error) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - char *resp = - cbm_mcp_handle_tool(srv, "manage_adr", - "{\"project\":\"cbm-no-such-project-zzz\",\"mode\":\"get\"}"); + char *resp = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"cbm-no-such-project-zzz\",\"mode\":\"get\"}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "or not indexed")); ASSERT_NOT_NULL(strstr(resp, "hint")); @@ -4160,8 +4193,7 @@ TEST(index_supervisor_gate_requires_marked_host_issue845) { if (signalled) { printf(" child killed by signal %d (alarm => recursive spawn chain hang)\n", sig); } else if (code != IDX845_OK) { - printf(" child exit code %d (41=worker spawned, 42=no result, 43=not indexed)\n", - code); + printf(" child exit code %d (41=worker spawned, 42=no result, 43=not indexed)\n", code); } ASSERT_FALSE(signalled); ASSERT_EQ(code, IDX845_OK); @@ -4714,8 +4746,8 @@ static void rm_add_budget_fixture(cbm_store_t *st, const char *project) { snprintf(name, sizeof(name), "f%02d", i); snprintf(qn, sizeof(qn), "q.f%02d", i); snprintf(sig, sizeof(sig), "f%02d(a, b, c int) (int, error)", i); - rm_add_node(st, project, "Function", name, qn, "pkg/file.go", - (double)(RM_FANOUT_COUNT - i), sig); + rm_add_node(st, project, "Function", name, qn, "pkg/file.go", (double)(RM_FANOUT_COUNT - i), + sig); } } @@ -4743,7 +4775,8 @@ static long rm_json_int(const char *json, const char *key) { return result; } -/* Read a string field out of a repo_map response's inner JSON text (caller frees). NULL if absent. */ +/* Read a string field out of a repo_map response's inner JSON text (caller frees). NULL if absent. + */ static char *rm_json_str(const char *json, const char *key) { if (!json) { return NULL; @@ -4777,7 +4810,7 @@ TEST(repo_map_dispatchable_via_full_jsonrpc) { cbm_mcp_server_t *srv = rm_setup_server("rm-dispatch"); cbm_store_t *st = cbm_mcp_server_store(srv); rm_add_node(st, "rm-dispatch", "Function", "Foo", "rm-dispatch.Foo", "pkg/foo.go", 5.0, - "Foo() error"); + "Foo() error"); char *resp = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":500,\"method\":\"tools/call\"," @@ -4866,8 +4899,8 @@ TEST(repo_map_budget_zero_or_negative_is_input_error) { cbm_store_t *st = cbm_mcp_server_store(srv); rm_add_budget_fixture(st, "rm-budget-bad"); - char *raw = cbm_mcp_handle_tool(srv, "repo_map", - "{\"project\":\"rm-budget-bad\",\"token_budget\":0}"); + char *raw = + cbm_mcp_handle_tool(srv, "repo_map", "{\"project\":\"rm-budget-bad\",\"token_budget\":0}"); ASSERT_NOT_NULL(raw); ASSERT_NOT_NULL(strstr(raw, "\"isError\":true")); /* Discriminating: must be the tool's own budget validation, not the @@ -4876,8 +4909,8 @@ TEST(repo_map_budget_zero_or_negative_is_input_error) { ASSERT_NULL(strstr(raw, "unknown tool")); free(raw); - raw = cbm_mcp_handle_tool(srv, "repo_map", - "{\"project\":\"rm-budget-bad\",\"token_budget\":-5}"); + raw = + cbm_mcp_handle_tool(srv, "repo_map", "{\"project\":\"rm-budget-bad\",\"token_budget\":-5}"); ASSERT_NOT_NULL(raw); ASSERT_NOT_NULL(strstr(raw, "\"isError\":true")); ASSERT_NOT_NULL(strstr(raw, "token_budget")); @@ -4899,8 +4932,8 @@ static void rm_add_seed_boost_fixture(cbm_store_t *st, const char *project) { rm_add_node(st, project, "Function", "D1", "proj.D1", "pkg/distant.go", 90.0, "D1() error"); rm_add_node(st, project, "Function", "D2", "proj.D2", "pkg/distant.go", 80.0, "D2() error"); - int64_t s = rm_add_node(st, project, "Function", "S", "proj.S", "pkg/seed.go", 5.0, - "S() error"); + int64_t s = + rm_add_node(st, project, "Function", "S", "proj.S", "pkg/seed.go", 5.0, "S() error"); int64_t n1 = rm_add_node(st, project, "Function", "N1", "proj.N1", "pkg/seed_n.go", 4.0, "N1() error"); int64_t n2 = @@ -4922,9 +4955,8 @@ TEST(repo_map_seed_boost_ranks_neighbourhood_above_distant) { rm_add_seed_boost_fixture(st, "rm-seed-boost"); /* Seeded: S's neighbourhood must outrank the distant high-importance cluster. */ - char *seeded = - rm_call(srv, "{\"project\":\"rm-seed-boost\",\"seed_anchors\":[\"S\"]," - "\"token_budget\":100000}"); + char *seeded = rm_call(srv, "{\"project\":\"rm-seed-boost\",\"seed_anchors\":[\"S\"]," + "\"token_budget\":100000}"); ASSERT_NOT_NULL(seeded); const char *s_pos = strstr(seeded, "S() error"); const char *d0_pos = strstr(seeded, "D0() error"); @@ -4955,9 +4987,8 @@ TEST(repo_map_tight_budget_seed_crowds_out_distant) { /* Budget fits only the top ~2 lines of the seeded ranking — since the * whole 5-member seed cluster outranks the distant trio, none of the * distant symbols can appear at all. */ - char *text = - rm_call(srv, "{\"project\":\"rm-seed-tight\",\"seed_anchors\":[\"S\"]," - "\"token_budget\":15}"); + char *text = rm_call(srv, "{\"project\":\"rm-seed-tight\",\"seed_anchors\":[\"S\"]," + "\"token_budget\":15}"); ASSERT_NOT_NULL(text); /* Positive discriminator first: the top seeded symbol IS in the map * (guards the red-boundary trivial pass where an error response also @@ -4977,7 +5008,7 @@ TEST(repo_map_seed_by_file_path) { rm_add_seed_boost_fixture(st, "rm-seed-file"); char *text = rm_call(srv, "{\"project\":\"rm-seed-file\",\"seed_anchors\":[\"pkg/seed.go\"]," - "\"token_budget\":100000}"); + "\"token_budget\":100000}"); ASSERT_NOT_NULL(text); const char *s_pos = strstr(text, "S() error"); const char *d0_pos = strstr(text, "D0() error"); @@ -5000,7 +5031,7 @@ TEST(repo_map_seed_resolves_multiple_nodes) { rm_add_node(st, project, "Function", "Dup", "proj.pkgC.Dup", "pkg/c.go", 2.0, "DupC() error"); char *text = rm_call(srv, "{\"project\":\"rm-seed-multi\",\"seed_anchors\":[\"Dup\"]," - "\"token_budget\":100000}"); + "\"token_budget\":100000}"); ASSERT_NOT_NULL(text); ASSERT_NOT_NULL(strstr(text, "\"seed_anchors_resolved\":1")); const char *dupb_pos = strstr(text, "DupB() error"); @@ -5035,23 +5066,23 @@ TEST(repo_map_weak_seed_triggers_widen_walk) { int64_t w = rm_add_node(st, project, "Function", "W", "rm-weak-seed.W", "pkg/weak.go", 1.0, "W() error"); - int64_t w1 = rm_add_node(st, project, "Function", "W1", "rm-weak-seed.W1", "pkg/other.go", - 1.0, "W1() error"); + int64_t w1 = rm_add_node(st, project, "Function", "W1", "rm-weak-seed.W1", "pkg/other.go", 1.0, + "W1() error"); rm_add_node(st, project, "Function", "WSibling", "rm-weak-seed.WSibling", "pkg/weak.go", 1.0, - "WSibling() error"); - int64_t w1a = rm_add_node(st, project, "Function", "W1a", "rm-weak-seed.W1a", - "pkg/other2.go", 1.0, "W1a() error"); + "WSibling() error"); + int64_t w1a = rm_add_node(st, project, "Function", "W1a", "rm-weak-seed.W1a", "pkg/other2.go", + 1.0, "W1a() error"); /* Raw importance between the widen boost (1*25=25) and the strong seed * boost (1*50=50) — proves the widen-boosted symbols outrank a *higher* * raw-importance unrelated symbol, not just "small graph, all fits". */ rm_add_node(st, project, "Function", "Filler", "rm-weak-seed.Filler", "pkg/filler.go", 10.0, - "Filler() error"); + "Filler() error"); rm_add_edge(st, project, w, w1, "CALLS"); rm_add_edge(st, project, w1, w1a, "CALLS"); char *text = rm_call(srv, "{\"project\":\"rm-weak-seed\",\"seed_anchors\":[\"W\"]," - "\"token_budget\":100000}"); + "\"token_budget\":100000}"); ASSERT_NOT_NULL(text); const char *wsib_pos = strstr(text, "WSibling() error"); const char *w1a_pos = strstr(text, "W1a() error"); @@ -5082,15 +5113,15 @@ TEST(repo_map_module_usage_neighbor_expands_to_its_file_symbols) { int64_t mod = rm_add_node_no_score(st, project, "Module", "pkg/user.py", "rm-module-nb.mod", "pkg/user.py"); rm_add_node(st, project, "Function", "UserHelper", "rm-module-nb.UserHelper", "pkg/user.py", - 1.0, "UserHelper() int"); + 1.0, "UserHelper() int"); /* Raw importance ABOVE the widened symbol's raw (1.0) but below its * boosted score (1x25): proves the expansion boost does the ranking. */ rm_add_node(st, project, "Function", "BigDeal", "rm-module-nb.BigDeal", "pkg/big.go", 20.0, - "BigDeal() error"); + "BigDeal() error"); rm_add_edge(st, project, mod, s2, "USAGE"); char *text = rm_call(srv, "{\"project\":\"rm-module-nb\",\"seed_anchors\":[\"S2\"]," - "\"token_budget\":100000}"); + "\"token_budget\":100000}"); ASSERT_NOT_NULL(text); const char *helper_pos = strstr(text, "UserHelper() int"); const char *big_pos = strstr(text, "BigDeal() error"); @@ -5112,8 +5143,8 @@ TEST(repo_map_no_seed_and_empty_seed_and_all_unresolvable_yield_identical_global rm_add_simple_fixture(st, "rm-empty-seed"); char *no_seed = rm_call(srv, "{\"project\":\"rm-empty-seed\",\"token_budget\":100000}"); - char *empty_arr = rm_call( - srv, "{\"project\":\"rm-empty-seed\",\"seed_anchors\":[],\"token_budget\":100000}"); + char *empty_arr = + rm_call(srv, "{\"project\":\"rm-empty-seed\",\"seed_anchors\":[],\"token_budget\":100000}"); char *all_unresolvable = rm_call(srv, "{\"project\":\"rm-empty-seed\",\"seed_anchors\":[\"nonexistent_xyz\"]," "\"token_budget\":100000}"); @@ -5358,8 +5389,7 @@ TEST(repo_map_param_only_signature_gets_name_prefix_and_ws_flatten) { n.start_line = 1; n.end_line = 4; /* JSON-escaped newlines inside the signature value. */ - n.properties_json = - "{\"importance\":10.0,\"signature\":\"(\\n self,\\n x: int\\n)\"}"; + n.properties_json = "{\"importance\":10.0,\"signature\":\"(\\n self,\\n x: int\\n)\"}"; ASSERT_GT(cbm_store_upsert_node(st, &n), 0); char *text = rm_call(srv, "{\"project\":\"rm-render-prefix\"}");