From c5bffb7fe6cbe15efd3c84f513c9bcda302956e3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 17 Jul 2026 22:32:53 +0200 Subject: [PATCH 1/6] feat(mcp): tree-format tool output, exactly-once pagination, lean defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 overhaul of the tool-output surface, built eval-first (byte harness + no-context field-eval agents, always vs the v0.9.0 release binary): Format — tree syntax everywhere: - compact_out emits tree tables: 'key: N (cols: a b c)' headers, indented space-delimited rows, '-' placeholders, space-aware quoting. Every table in every tool converts mechanically. - Grouped prefix-factored output is the DEFAULT for search_graph and trace_path: rows grouped under their shared (qn-prefix, file) printed once, row qn = group prefix + '.' + name (stated in-band). Measured: 192-hit search 2,929B (was 4,811 TOON / 238,466 v0.9.0), 82-row trace 2,256B (was 4,234 / 8,094). Field eval: 5/5 answers correct, QN reconstruction verified, no comprehension loss. - detail:'ids' tier: bare-qn enumeration for wide sweeps (-44%). Pagination — exactly-once, stateless: - store_meta generation (per-DB random uid + mutation counter bumped in cbm_store_upsert_project — every index run's choke point): cursors can never silently validate against a rebuilt DB. - trace cursors: opaque token (leg, generation, params-hash, (hop,id) watermark); pages drain callees then callers with exact callees_total/callers_total on every page; teaching errors for params-mismatch and staleness. Guard: 12 callees paged 5+5+2, every row on exactly one page. - deterministic ordering: BFS ORDER BY hop,id; canonical (hop,id) sort + min-hop dedup across same-name seeds (hop fed risk_labels wrong before); search (name,id)/(rank,id) tie-breaks make offset pages stable. Lean defaults + teaching errors: - get_graph_schema no longer advertises blocked internal fields (fp/sp/bt). - index_repository coverage lists: counts + 5 examples + one-line pointer notes (full record was always in the logfile); 6.0KB -> 1.5KB here, 53KB observed on a large repo. index_status gates the git context block behind verbose:true. - trace limit param (<=5000) + truncated:true — never a silent truncation; invalid direction now errors with the valid values; requesting core columns via fields hints instead of emitting empty cells; cypher resolves f.file/f.qn/f.path aliases. Trace-correctness fixes (reproduce-first, RED verified): - bfs_collect_edges 4KB id-string overflow: past ~1000 visited nodes the edge SQL failed to prepare and EVERY trace edge silently vanished; now a per-connection temp-table join (star guard: 1200/1200 edges). - bfs_union_same_name: min-hop across seeds + edge dedup. Guards: gating tool-output regression suite (context-explosion detector: blocked-field leaks, repeated-key envelopes, oversized notes + calibrated byte ceilings + semantic floors). Full suite 6,367 passed / 0 failed. Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 5 + src/mcp/compact_out.c | 34 +- src/mcp/mcp.c | 623 +++++++++++++++++++++++++++++++--- src/store/store.c | 96 +++++- src/store/store.h | 5 + tests/test_index_resilience.c | 8 +- tests/test_integration.c | 54 +++ tests/test_mcp.c | 435 +++++++++++++++++++++++- tests/test_store_pragmas.c | 37 +- 9 files changed, 1200 insertions(+), 97 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 64e10a328..98ffb4c97 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -2163,8 +2163,13 @@ static const char *node_string_field(const cbm_node_t *n, const char *prop) { } fields[] = { {"name", offsetof(cbm_node_t, name)}, {"qualified_name", offsetof(cbm_node_t, qualified_name)}, + /* Aliases: field-eval agents reach for the short names, and a miss + * used to return a silent empty column costing a round-trip. */ + {"qn", offsetof(cbm_node_t, qualified_name)}, {"label", offsetof(cbm_node_t, label)}, {"file_path", offsetof(cbm_node_t, file_path)}, + {"file", offsetof(cbm_node_t, file_path)}, + {"path", offsetof(cbm_node_t, file_path)}, }; for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) { if (strcmp(prop, fields[i].key) == 0) { diff --git a/src/mcp/compact_out.c b/src/mcp/compact_out.c index db05aa65b..729270225 100644 --- a/src/mcp/compact_out.c +++ b/src/mcp/compact_out.c @@ -99,17 +99,17 @@ static bool looks_numeric(const char *s) { static bool needs_quotes(const char *s) { if (!s || !*s) { - return true; /* empty cell must be visible as "" */ - } - if (isspace((unsigned char)s[0]) || isspace((unsigned char)s[strlen(s) - 1])) { - return true; + return false; /* empty cells emit as the "-" placeholder, not quotes */ } for (const char *p = s; *p; p++) { - if (*p == ',' || *p == '"' || *p == '\n' || *p == '\r') { + /* Space-delimited rows: any internal whitespace or quote forces + * quoting so column positions stay parseable. */ + if (isspace((unsigned char)*p) || *p == '"' || *p == '\r') { return true; } } - if (strcmp(s, "true") == 0 || strcmp(s, "false") == 0 || strcmp(s, "null") == 0) { + if (strcmp(s, "true") == 0 || strcmp(s, "false") == 0 || strcmp(s, "null") == 0 || + strcmp(s, "-") == 0) { return true; } return looks_numeric(s); @@ -140,6 +140,10 @@ static void append_quoted(cbm_sb_t *sb, const char *s) { } static void append_value(cbm_sb_t *sb, const char *s) { + if (!s || !*s) { + cbm_sb_append_n(sb, "-", 1); /* stable column positions for empties */ + return; + } if (needs_quotes(s)) { append_quoted(sb, s); } else { @@ -174,19 +178,19 @@ void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v) { /* ── Tables ─────────────────────────────────────────────────────── */ +/* Tree-syntax table header: `key: N (cols: a b c)` — count first (agents + * read scale before rows), column names once, rows indented beneath. */ void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols, int ncols) { char num[32]; - snprintf(num, sizeof(num), "[%d]{", n); + snprintf(num, sizeof(num), ": %d (cols:", n); cbm_sb_append(sb, key); cbm_sb_append(sb, num); for (int i = 0; i < ncols; i++) { - if (i > 0) { - cbm_sb_append_n(sb, ",", 1); - } + cbm_sb_append_n(sb, " ", 1); cbm_sb_append(sb, cols[i]); } - cbm_sb_append_n(sb, "}:\n", 3); + cbm_sb_append_n(sb, ")\n", 2); } void cbm_toon_row_begin(cbm_sb_t *sb) { @@ -195,7 +199,7 @@ void cbm_toon_row_begin(cbm_sb_t *sb) { void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first) { if (!first) { - cbm_sb_append_n(sb, ",", 1); + cbm_sb_append_n(sb, " ", 1); } append_value(sb, val ? val : ""); } @@ -204,7 +208,7 @@ void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first) { char num[32]; snprintf(num, sizeof(num), "%lld", v); if (!first) { - cbm_sb_append_n(sb, ",", 1); + cbm_sb_append_n(sb, " ", 1); } cbm_sb_append(sb, num); } @@ -213,14 +217,14 @@ void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first) { char num[48]; snprintf(num, sizeof(num), "%.4g", v); if (!first) { - cbm_sb_append_n(sb, ",", 1); + cbm_sb_append_n(sb, " ", 1); } cbm_sb_append(sb, num); } void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first) { if (!first) { - cbm_sb_append_n(sb, ",", 1); + cbm_sb_append_n(sb, " ", 1); } cbm_sb_append(sb, v ? "true" : "false"); } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 804222436..b16304663 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -13,7 +13,6 @@ enum { MCP_FIELD_SIZE = 1040, MCP_TIMEOUT_MS = 1000, MCP_HALF_SEC_US = 500000, - MCP_MAX_ROWS = 100, MCP_COL_2 = 2, MCP_COL_3 = 3, MCP_COL_4 = 4, @@ -26,7 +25,8 @@ enum { MCP_DEFAULT_DEPTH = 3, MCP_DEFAULT_BFS_DEPTH = 2, MCP_DEFAULT_LIMIT = 10, - MCP_BFS_LIMIT = 100, + MCP_BFS_LIMIT = 100, /* default per-direction trace budget (limit param raises) */ + MCP_BFS_LIMIT_MAX = 5000, /* hard ceiling for the limit param (context-bomb guard) */ MCP_N_DEFAULTS_2 = 2, MCP_URI_PREFIX = 7, /* strlen("file://") */ MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */ @@ -337,11 +337,12 @@ static const tool_def_t TOOLS[] = { "COVERAGE: the response reports files that were NOT fully indexed — 'skipped' (not " "indexed at all: oversized/read/parse failures) and 'parse_partial' (indexed, but " "constructs inside the listed line ranges could not be parsed and MAY be missing from " - "the graph). Query the persisted signal any time via index_status or " - "structurally via query_graph(graph=\"missed\"). Both signals are best-effort: absence " - "of a flag is NOT a completeness guarantee; prefer grep inside flagged ranges. " - "Separately, 'excluded' + 'not_indexed_files' list what was deliberately NOT indexed " - "(gitignore/.cbmignore/skip-lists) — by design, not failures.", + "the graph). The embedded lists carry counts plus a FEW EXAMPLES only; the complete " + "lists are in the per-run 'logfile' (path in the response) and queryable any time via " + "index_status or structurally via query_graph(graph=\"missed\"). Both signals are " + "best-effort: absence of a flag is NOT a completeness guarantee; prefer grep inside " + "flagged ranges. Separately, 'excluded' + 'not_indexed_files' list what was " + "deliberately NOT indexed (gitignore/.cbmignore/skip-lists) — by design, not failures.", "{\"type\":\"object\",\"properties\":{\"repo_path\":{\"type\":\"string\",\"description\":" "\"Path to the repository\"}," "\"mode\":{\"type\":\"string\"," @@ -402,13 +403,17 @@ static const tool_def_t TOOLS[] = { "detect the limit and paginate.\"},\"offset\":{\"type\":\"integer\",\"default\":0," "\"description\":\"Skip the first N matching nodes. Combine with 'limit' to page: " "increment offset by limit and re-call while has_more is true.\"}," - "\"format\":{\"type\":\"string\",\"enum\":[\"toon\",\"json\"],\"default\":\"toon\"," + "\"format\":{\"type\":\"string\",\"enum\":[\"toon\",\"tree\",\"json\"],\"default\":\"toon\"," "\"description\":\"Response encoding. toon (default): compact header+rows tables, " "~60% fewer tokens. json: legacy verbose per-node objects.\"}," "\"fields\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":" "\"Extra per-node property columns for toon output, e.g. complexity, cognitive, " - "signature, docstring, return_type, is_test, lines(int). Missing values emit as " - "empty cells.\"}}," + "signature, docstring, return_type, is_test, lines(int). Core row columns " + "(qn/label/file/lines/in/out) are always present — do not request them here. " + "Missing values emit as empty cells.\"}," + "\"detail\":{\"type\":\"string\",\"enum\":[\"ids\",\"default\"],\"default\":\"default\"," + "\"description\":\"ids: bare qualified-name enumeration (one column) — cheapest form " + "for wide sweeps where per-row metadata is noise. default: full rows.\"}}," "\"required\":[\"project\"]}"}, {"query_graph", "Query graph", @@ -451,10 +456,18 @@ static const tool_def_t TOOLS[] = { "Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing. " "RESPONSE: compact TOON tables — `callees[N]{qn,hop}:`/`callers[N]{qn,hop}:` headers then " "one row per reached node (hop = BFS distance); risk/test/args columns appear when the " - "matching flags are set. Pass format=\"json\" for legacy verbose objects.", + "matching flags are set. `truncated: true` means a direction hit the node budget — re-call " + "with a higher 'limit'. Pass format=\"json\" for legacy verbose objects.", "{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"project\":{" "\"type\":\"string\"},\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\"," - "\"both\"],\"default\":\"both\"},\"depth\":{\"type\":\"integer\",\"default\":3},\"mode\":{" + "\"both\"],\"default\":\"both\"},\"depth\":{\"type\":\"integer\",\"default\":3}," + "\"limit\":{\"type\":\"integer\",\"default\":100,\"minimum\":1,\"maximum\":5000," + "\"description\":\"Rows per page. callees_total/callers_total always carry the exact full " + "counts; when a page is truncated the response carries next — see cursor.\"}," + "\"cursor\":{\"type\":\"string\",\"description\":\"Resume token from a previous response's " + "'next' field. Pass it back with ALL other arguments identical to get the following page " + "with no duplicates. Cursors outlive nothing: after a reindex you get a stale_cursor error " + "— just re-run the original query.\"},\"mode\":{" "\"type\":\"string\",\"enum\":[\"calls\",\"data_flow\",\"cross_service\"],\"default\":" "\"calls\",\"description\":\"calls: follow CALLS edges. data_flow: follow CALLS+DATA_FLOWS " "with arg expressions. cross_service: follow HTTP_CALLS+ASYNC_CALLS+DATA_FLOWS through " @@ -467,7 +480,7 @@ static const tool_def_t TOOLS[] = { "\"},\"include_tests\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Include test files in results. When false (default), test files are " "filtered out. When true, test nodes are included with a test column/marker.\"}," - "\"format\":{\"type\":\"string\",\"enum\":[\"toon\",\"json\"],\"default\":\"toon\"," + "\"format\":{\"type\":\"string\",\"enum\":[\"toon\",\"tree\",\"json\"],\"default\":\"toon\"," "\"description\":\"Response encoding. toon (default): compact header+rows tables. " "json: legacy verbose per-hop objects.\"}}," "\"required\":[\"function_name\",\"project\"]}"}, @@ -552,7 +565,10 @@ static const tool_def_t TOOLS[] = { "query_graph(graph=\"missed\"). The report also carries 'not_indexed' — files/dirs excluded " "BY DESIGN (gitignore/.cbmignore/skip-lists): deliberate and deterministic, not failures; " "change the ignore rules and re-index to include them.", - "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":[" + "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," + "\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Include the git " + "context block (worktree/shadow path variants). Only needed when debugging where an index " + "lives — omitted by default to keep the status lean.\"}},\"required\":[" "\"project\"]}"}, {"check_index_coverage", "Check index coverage", @@ -2012,6 +2028,8 @@ static char *verify_project_indexed(cbm_store_t *store, const char *project) { return NULL; } +static bool sg_field_blocked(const char *f); /* internal-only fields, defined with search_graph */ + static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); @@ -2037,6 +2055,13 @@ static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, lbl, "count", schema.node_labels[i].count); yyjson_mut_val *props = yyjson_mut_arr(doc); for (int j = 0; j < schema.node_labels[i].property_count; j++) { + /* Internal similarity intermediates (fp/sp/bt) are blocked from every + * tool response — advertising them here invited agents to request + * fields the server then silently refuses. Filter them from the + * schema so it only describes obtainable properties. */ + if (sg_field_blocked(schema.node_labels[i].properties[j])) { + continue; + } yyjson_mut_arr_add_str(doc, props, schema.node_labels[i].properties[j]); } yyjson_mut_obj_add_val(doc, lbl, "properties", props); @@ -2276,7 +2301,9 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu "WHERE n.project = ?2 " " AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') " " AND (?6 IS NULL OR n.file_path LIKE ?6) " - "ORDER BY rank " + /* rank ties are common (boosted floats) — the id tie-break makes + * offset pages contractually stable across calls. */ + "ORDER BY rank, n.id " "LIMIT ?3 OFFSET ?4"; sqlite3_stmt *stmt = NULL; @@ -2558,12 +2585,27 @@ static bool sg_field_blocked(const char *f) { return strcmp(f, "fp") == 0 || strcmp(f, "sp") == 0 || strcmp(f, "bt") == 0; } +/* Core row columns every search result already carries. Requesting one as an + * extra `fields` entry used to emit a silent empty column (core values are + * node columns, never in the properties JSON) — field-eval agents burned a + * round-trip on exactly that. Drop them and teach instead. */ +static bool sg_field_is_core(const char *f) { + return strcmp(f, "qn") == 0 || strcmp(f, "qualified_name") == 0 || strcmp(f, "name") == 0 || + strcmp(f, "label") == 0 || strcmp(f, "file") == 0 || strcmp(f, "file_path") == 0 || + strcmp(f, "path") == 0 || strcmp(f, "lines") == 0 || strcmp(f, "in") == 0 || + strcmp(f, "out") == 0; +} + /* Parse the `fields` argument (array of property names) into out[] as * pointers owned by the returned doc (caller frees the doc after emission). - * Blocked internal fields are silently dropped. */ -static int sg_parse_fields(const char *args, const char *out[], int max_out, - yyjson_doc **out_owner) { + * Blocked internal fields are silently dropped; core-column requests are + * dropped too and reported via *core_requested so the emitter can hint. */ +static int sg_parse_fields(const char *args, const char *out[], int max_out, yyjson_doc **out_owner, + bool *core_requested) { *out_owner = NULL; + if (core_requested) { + *core_requested = false; + } yyjson_doc *args_doc = yyjson_read(args, strlen(args), 0); yyjson_val *args_root = args_doc ? yyjson_doc_get_root(args_doc) : NULL; yyjson_val *fv = args_root ? yyjson_obj_get(args_root, "fields") : NULL; @@ -2579,7 +2621,16 @@ static int sg_parse_fields(const char *args, const char *out[], int max_out, yyjson_val *item; yyjson_arr_foreach(fv, idx, max, item) { const char *s = yyjson_get_str(item); - if (s && s[0] && !sg_field_blocked(s) && n < max_out) { + if (!s || !s[0] || sg_field_blocked(s)) { + continue; + } + if (sg_field_is_core(s)) { + if (core_requested) { + *core_requested = true; + } + continue; + } + if (n < max_out) { out[n++] = s; } } @@ -2627,8 +2678,21 @@ static void sg_lines_str(char *out, size_t sz, int start, int end) { /* Emit the regex-path search results as a TOON table. */ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *out, int offset, - const char *const *fields, int nfields) { + const char *const *fields, int nfields, bool detail_ids) { cbm_toon_scalar_int(sb, "total", out->total); + if (detail_ids) { + /* ids tier: bare qn enumeration — for "list everything matching X" + * sweeps where per-row metadata is noise (LocAgent's fold tier). */ + static const char *const id_cols[] = {"qn"}; + cbm_toon_table_header(sb, "results", out->count, id_cols, 1); + for (int i = 0; i < out->count; i++) { + cbm_toon_row_begin(sb); + cbm_toon_cell_str(sb, out->results[i].node.qualified_name, true); + cbm_toon_row_end(sb); + } + cbm_toon_scalar_bool(sb, "has_more", out->total > offset + out->count); + return; + } const char *cols[6 + SG_MAX_EXTRA_FIELDS] = {"qn", "label", "file", "lines", "in", "out"}; int ncols = 6; for (int f = 0; f < nfields; f++) { @@ -2652,6 +2716,101 @@ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *ou cbm_toon_scalar_bool(sb, "has_more", out->total > offset + out->count); } +/* ── Tree format (Phase-2 A/B candidate) ──────────────────────────── + * Prefix-factored, file-grouped output: the shared (qn-prefix, file) pair is + * printed ONCE per group, rows beneath carry only the short name + data + * cells. The reconstruction rule (qn = group-prefix + "." + name) is stated + * once in the header so agents can copy exact join keys into follow-up + * calls. Research basis: HDT front-coding (prefix factoring), LocAgent tree + * ablation (tree > flat/DOT for LLM comprehension), Lost-in-Distance + * (related rows adjacent — grouping by module does exactly that). */ + +/* qn-prefix = qualified_name minus its last '.'-segment. Returns length. */ +static size_t sg_qn_prefix_len(const char *qn) { + const char *last = qn ? strrchr(qn, '.') : NULL; + return last ? (size_t)(last - qn) : 0; +} + +static int sg_cmp_by_qn(const void *pa, const void *pb) { + const cbm_search_result_t *a = (const cbm_search_result_t *)pa; + const cbm_search_result_t *b = (const cbm_search_result_t *)pb; + 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); +} + +static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int offset, + const char *const *fields, int nfields) { + char buf[CBM_SZ_512]; + char extra_cols[CBM_SZ_256] = ""; + for (int f = 0; f < nfields; f++) { + strncat(extra_cols, " ", sizeof(extra_cols) - strlen(extra_cols) - 1); + strncat(extra_cols, fields[f], sizeof(extra_cols) - strlen(extra_cols) - 1); + } + snprintf(buf, sizeof(buf), + "total: %d\nresults: %d (rows: name label lines in out%s; " + "qn = group prefix + \".\" + name)\n", + out->total, out->count, extra_cols); + cbm_sb_append(sb, buf); + /* Sort by qn so same-prefix rows are adjacent (module clustering). */ + if (out->count > 1) { + qsort(out->results, (size_t)out->count, sizeof(cbm_search_result_t), sg_cmp_by_qn); + } + char cur_group[CBM_SZ_1K] = ""; + for (int i = 0; i < out->count; i++) { + const cbm_search_result_t *sr = &out->results[i]; + const char *qn = sr->node.qualified_name ? sr->node.qualified_name : ""; + const char *file = sr->node.file_path ? sr->node.file_path : ""; + size_t plen = sg_qn_prefix_len(qn); + char group[CBM_SZ_1K]; + snprintf(group, sizeof(group), "%.*s (%s)", (int)plen, qn, file); + if (strcmp(group, cur_group) != 0) { + snprintf(cur_group, sizeof(cur_group), "%s", group); + cbm_sb_append(sb, group); + cbm_sb_append(sb, ":\n"); + } + const char *shortname = plen ? qn + plen + 1 : qn; + char lines[CBM_SZ_32]; + sg_lines_str(lines, sizeof(lines), sr->node.start_line, sr->node.end_line); + char row[CBM_SZ_1K]; + snprintf(row, sizeof(row), " %s %s %s %d %d", shortname, + sr->node.label ? sr->node.label : "", lines, sr->in_degree, sr->out_degree); + cbm_sb_append(sb, row); + /* Extra property columns (fields param), space-delimited; missing + * values emit as "-" so column positions stay stable. */ + if (nfields > 0) { + yyjson_doc *pd = (sr->node.properties_json && sr->node.properties_json[0]) + ? yyjson_read(sr->node.properties_json, + strlen(sr->node.properties_json), 0) + : NULL; + yyjson_val *pr = pd ? yyjson_doc_get_root(pd) : NULL; + for (int f = 0; f < nfields; f++) { + yyjson_val *v = (pr && yyjson_is_obj(pr)) ? yyjson_obj_get(pr, fields[f]) : NULL; + char cell[CBM_SZ_256]; + if (v && yyjson_is_str(v)) { + snprintf(cell, sizeof(cell), " %s", yyjson_get_str(v)); + } else if (v && yyjson_is_int(v)) { + snprintf(cell, sizeof(cell), " %lld", (long long)yyjson_get_int(v)); + } else if (v && yyjson_is_real(v)) { + snprintf(cell, sizeof(cell), " %.3g", yyjson_get_real(v)); + } else if (v && yyjson_is_bool(v)) { + snprintf(cell, sizeof(cell), " %s", yyjson_get_bool(v) ? "true" : "false"); + } else { + snprintf(cell, sizeof(cell), " -"); + } + cbm_sb_append(sb, cell); + } + if (pd) { + yyjson_doc_free(pd); + } + } + cbm_sb_append(sb, "\n"); + } + snprintf(buf, sizeof(buf), "has_more: %s\n", + out->total > offset + out->count ? "true" : "false"); + cbm_sb_append(sb, buf); +} + /* Emit semantic vector-search results as a TOON table. */ static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, int vcount) { @@ -2680,12 +2839,16 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { /* Response encoding: TOON tables by default (compact, header+rows). * format:"json" restores the legacy verbose per-node objects; nested - * neighbor lists (include_connected) need them, so they force JSON. */ + * neighbor lists (include_connected) need them, so they force JSON. + * format:"tree" is the Phase-2 A/B candidate: rows grouped by + * (qn-prefix, file) with the shared prefix printed once. */ char *format_arg = cbm_mcp_get_string_arg(args, "format"); bool legacy_json = format_arg && strcmp(format_arg, "json") == 0; + bool tree_format = format_arg && strcmp(format_arg, "tree") == 0; free(format_arg); if (cbm_mcp_get_bool_arg(args, "include_connected")) { legacy_json = true; + tree_format = false; } /* BM25 path: if `query` is set, run FTS5 full-text search with ranking @@ -2750,7 +2913,12 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { if (!legacy_json) { const char *fields[SG_MAX_EXTRA_FIELDS]; yyjson_doc *fields_owner = NULL; - int nfields = sg_parse_fields(args, fields, SG_MAX_EXTRA_FIELDS, &fields_owner); + char *sg_detail = cbm_mcp_get_string_arg(args, "detail"); + bool detail_ids = sg_detail && strcmp(sg_detail, "ids") == 0; + free(sg_detail); + bool core_fields_requested = false; + int nfields = sg_parse_fields(args, fields, SG_MAX_EXTRA_FIELDS, &fields_owner, + &core_fields_requested); cbm_vector_result_t *vresults = NULL; int vcount = 0; @@ -2771,7 +2939,21 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { cbm_search_output_t tout = {0}; if (!semantic_only) { cbm_store_search(store, ¶ms, &tout); - emit_search_results_toon(&sb, &tout, offset, fields, nfields); + /* Grouped tree output is THE default; the flat table remains + * only for detail:"ids" (single column — nothing to group). */ + (void)tree_format; + if (detail_ids) { + emit_search_results_toon(&sb, &tout, offset, fields, nfields, detail_ids); + } else { + emit_search_results_tree(&sb, &tout, offset, fields, nfields); + } + if (core_fields_requested) { + cbm_toon_scalar_str( + &sb, "hint", + "some requested fields (file/name/qn/label/lines) are already core " + "row columns and were skipped — `fields` is for extra property " + "columns like complexity, cognitive, signature"); + } if (tout.total == 0) { if (name_pattern && label) { cbm_toon_scalar_str(&sb, "hint", @@ -3569,6 +3751,10 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); + /* The git context block (worktree/shadow path variants) only matters when + * debugging index-location issues — gate it so the common status call + * stays lean. */ + bool verbose = cbm_mcp_get_bool_arg(args, "verbose"); yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); @@ -3585,7 +3771,9 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { if (cbm_store_get_project(store, project, &proj_info) == CBM_STORE_OK) { yyjson_mut_obj_add_strcpy(doc, root, "root_path", proj_info.root_path ? proj_info.root_path : ""); - add_git_context_json(doc, root, proj_info.root_path); + if (verbose) { + add_git_context_json(doc, root, proj_info.root_path); + } safe_str_free(&proj_info.name); safe_str_free(&proj_info.indexed_at); safe_str_free(&proj_info.root_path); @@ -4620,6 +4808,18 @@ static int pick_resolved_node(const cbm_node_t *nodes, int count, bool *ambiguou return best; } +static int node_hop_cmp_hop_id(const void *pa, const void *pb) { + const cbm_node_hop_t *a = (const cbm_node_hop_t *)pa; + const cbm_node_hop_t *b = (const cbm_node_hop_t *)pb; + if (a->hop != b->hop) { + return a->hop < b->hop ? -1 : 1; + } + if (a->node.id != b->node.id) { + return a->node.id < b->node.id ? -1 : 1; + } + return 0; +} + /* BFS from EVERY node sharing the resolved name and merge the results, so the * caller/callee set is complete even when one logical symbol is represented by * more than one graph node — e.g. a real .ts implementation plus an ambient @@ -4629,17 +4829,24 @@ static int pick_resolved_node(const cbm_node_t *nodes, int count, bool *ambiguou * transfers into *out, freed by cbm_store_traverse_free. */ static void bfs_union_same_name(cbm_store_t *store, const cbm_node_t *nodes, int node_count, const char *direction, const char **edge_types, int edge_type_count, - int depth, cbm_traverse_result_t *out) { + int depth, int limit, cbm_traverse_result_t *out) { memset(out, 0, sizeof(*out)); int vcap = 0, ecap = 0; for (int k = 0; k < node_count; k++) { cbm_traverse_result_t tr = {0}; - cbm_store_bfs(store, nodes[k].id, direction, edge_types, edge_type_count, depth, - MCP_BFS_LIMIT, &tr); + cbm_store_bfs(store, nodes[k].id, direction, edge_types, edge_type_count, depth, limit, + &tr); for (int i = 0; i < tr.visited_count; i++) { bool dup = false; for (int j = 0; j < out->visited_count; j++) { if (out->visited[j].node.id == tr.visited[i].node.id) { + /* Min-hop across seeds: keep-first recorded the EARLIER + * seed's (possibly longer) distance; hop feeds risk_labels + * and pagination watermarks, so it must match the + * single-BFS MIN(hop) semantics (#797). */ + if (tr.visited[i].hop < out->visited[j].hop) { + out->visited[j].hop = tr.visited[i].hop; + } dup = true; break; } @@ -4655,6 +4862,20 @@ static void bfs_union_same_name(cbm_store_t *store, const cbm_node_t *nodes, int memset(&tr.visited[i], 0, sizeof(tr.visited[i])); /* ownership moved */ } for (int i = 0; i < tr.edge_count; i++) { + /* Overlapping seed neighborhoods yield the same edge from more + * than one BFS — dedup by (source, target, type). */ + bool edup = false; + for (int j = 0; j < out->edge_count; j++) { + if (out->edges[j].source_id == tr.edges[i].source_id && + out->edges[j].target_id == tr.edges[i].target_id && out->edges[j].type && + tr.edges[i].type && strcmp(out->edges[j].type, tr.edges[i].type) == 0) { + edup = true; + break; + } + } + if (edup) { + continue; + } if (out->edge_count >= ecap) { ecap = ecap ? ecap * 2 : 8; out->edges = safe_realloc(out->edges, ecap * sizeof(cbm_edge_info_t)); @@ -4664,6 +4885,166 @@ static void bfs_union_same_name(cbm_store_t *store, const cbm_node_t *nodes, int } cbm_store_traverse_free(&tr); /* frees only the un-moved (root + dup) fields */ } + /* Canonical (hop, id) order — a pure function of the graph, independent of + * seed iteration order; required for deterministic output and watermarks. */ + if (out->visited_count > 1) { + qsort(out->visited, (size_t)out->visited_count, sizeof(cbm_node_hop_t), + node_hop_cmp_hop_id); + } +} + +/* ── Pagination cursors (stateless, exactly-once) ──────────────────── + * Token: "c1....." — version, trace leg + * (o=callees, i=callers), the store generation (per-DB uid + mutation + * counter), an FNV-1a-64 hash of the canonical query params, and the + * (hop, node_id) watermark of the last emitted row in canonical order. + * Stateless by design: the server re-traverses (the recursive CTE pays the + * full reachable-set cost regardless of LIMIT, so a page costs what one + * call costs today) and skips to the watermark. The generation stamp turns + * every post-reindex cursor into a loud, actionable error — node ids are + * never reused across rebuilds, so silently resuming would be wrong. */ + +static uint64_t cursor_fnv1a64(const char *s, uint64_t h) { + while (s && *s) { + h ^= (uint64_t)(unsigned char)*s++; + h *= 0x100000001b3ULL; + } + return h; +} + +typedef struct { + char leg; /* 'o' callees, 'i' callers */ + char generation[96]; /* store generation at mint time */ + uint64_t qhash; /* canonical-params hash */ + int hop; /* watermark: last emitted row */ + int64_t node_id; +} trace_cursor_t; + +/* Hash the params that define the traversal identity. A cursor replayed with + * different params must fail loudly, never silently mis-skip. */ +static uint64_t trace_params_hash(const char *project, const char *func_name, const char *direction, + const char *mode, int depth, bool include_tests, int limit) { + uint64_t h = 0xcbf29ce484222325ULL; + h = cursor_fnv1a64(project ? project : "", h); + h = cursor_fnv1a64("|", h); + h = cursor_fnv1a64(func_name ? func_name : "", h); + h = cursor_fnv1a64("|", h); + h = cursor_fnv1a64(direction ? direction : "", h); + h = cursor_fnv1a64("|", h); + h = cursor_fnv1a64(mode ? mode : "", h); + char nums[64]; + snprintf(nums, sizeof(nums), "|%d|%d|%d", depth, include_tests ? 1 : 0, limit); + h = cursor_fnv1a64(nums, h); + return h; +} + +static void trace_cursor_encode(const trace_cursor_t *c, char *buf, size_t bufsz) { + snprintf(buf, bufsz, "c1.%c.%s.%016llx.%d.%lld", c->leg, c->generation, + (unsigned long long)c->qhash, c->hop, (long long)c->node_id); +} + +/* Decode + validate. Returns NULL on success, else a static teaching error. */ +static const char *trace_cursor_decode(const char *token, const char *current_generation, + uint64_t expected_qhash, trace_cursor_t *out) { + memset(out, 0, sizeof(*out)); + if (!token || strncmp(token, "c1.", 3) != 0) { + return "invalid_cursor: unrecognized token — re-run the original query without 'cursor'"; + } + const char *p = token + 3; + if (*p != 'o' && *p != 'i') { + return "invalid_cursor: unrecognized token — re-run the original query without 'cursor'"; + } + out->leg = *p; + p += 2; /* leg + '.' */ + const char *gen_end = strchr(p, '.'); + if (!gen_end || (size_t)(gen_end - p) >= sizeof(out->generation)) { + return "invalid_cursor: unrecognized token — re-run the original query without 'cursor'"; + } + memcpy(out->generation, p, (size_t)(gen_end - p)); + out->generation[gen_end - p] = '\0'; + unsigned long long qh = 0; + long long nid = 0; + if (sscanf(gen_end + 1, "%16llx.%d.%lld", &qh, &out->hop, &nid) != 3) { + return "invalid_cursor: unrecognized token — re-run the original query without 'cursor'"; + } + out->qhash = qh; + out->node_id = nid; + if (out->qhash != expected_qhash) { + return "cursor_params_mismatch: this cursor was issued for different arguments — " + "pass the cursor back with ALL other arguments identical"; + } + if (strcmp(out->generation, current_generation) != 0) { + return "stale_cursor: the project was reindexed since this cursor was issued — " + "re-run the original query without 'cursor' (node identities changed)"; + } + return NULL; +} + +/* Slice a canonically-ordered traversal at the watermark: index of the first + * row strictly AFTER (hop, id). */ +static int trace_watermark_index(const cbm_traverse_result_t *tr, int hop, int64_t node_id) { + for (int i = 0; i < tr->visited_count; i++) { + if (tr->visited[i].hop > hop || + (tr->visited[i].hop == hop && tr->visited[i].node.id > node_id)) { + return i; + } + } + return tr->visited_count; +} + +/* Tree-format trace leg: rows grouped by qn-prefix (printed once), each row + * `name hop` — same data as the TOON table, prefix-factored. Test-file rows + * honor include_tests exactly like bfs_to_toon_table. Rows arrive in + * canonical (hop,id) order; grouping re-sorts by (prefix, hop, id) so + * same-module rows are adjacent (Lost-in-Distance) while hop stays visible. */ +static int tree_hop_cmp_qn(const void *pa, const void *pb) { + const cbm_node_hop_t *a = (const cbm_node_hop_t *)pa; + const cbm_node_hop_t *b = (const cbm_node_hop_t *)pb; + const char *qa = a->node.qualified_name ? a->node.qualified_name : ""; + const char *qb = b->node.qualified_name ? b->node.qualified_name : ""; + int c = strcmp(qa, qb); + if (c != 0) { + return c; + } + return a->hop - b->hop; +} + +static void bfs_to_tree_table(cbm_sb_t *sb, const char *key, cbm_traverse_result_t *tr, + bool include_tests) { + int visible = 0; + for (int i = 0; i < tr->visited_count; i++) { + if (!include_tests && is_test_file(tr->visited[i].node.file_path)) { + continue; + } + visible++; + } + char buf[CBM_SZ_256]; + snprintf(buf, sizeof(buf), "%s: %d (rows: name hop; qn = group prefix + \".\" + name)\n", key, + visible); + cbm_sb_append(sb, buf); + if (tr->visited_count > 1) { + qsort(tr->visited, (size_t)tr->visited_count, sizeof(cbm_node_hop_t), tree_hop_cmp_qn); + } + char cur_group[CBM_SZ_1K] = ""; + for (int i = 0; i < tr->visited_count; i++) { + if (!include_tests && is_test_file(tr->visited[i].node.file_path)) { + continue; + } + const char *qn = + tr->visited[i].node.qualified_name ? tr->visited[i].node.qualified_name : ""; + size_t plen = sg_qn_prefix_len(qn); + if (plen >= sizeof(cur_group)) { + plen = 0; + } + if (strncmp(cur_group, qn, plen) != 0 || cur_group[plen] != '\0') { + snprintf(cur_group, sizeof(cur_group), "%.*s", (int)plen, qn); + cbm_sb_append(sb, cur_group); + cbm_sb_append(sb, ":\n"); + } + char row[CBM_SZ_512]; + snprintf(row, sizeof(row), " %s %d\n", plen ? qn + plen + 1 : qn, tr->visited[i].hop); + cbm_sb_append(sb, row); + } } /* Clamp a client-supplied traversal depth to the MCP ceiling (cbm_mcp_max_depth), @@ -4691,6 +5072,18 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { char *param_name = cbm_mcp_get_string_arg(args, "parameter_name"); int depth = cbm_mcp_get_int_arg(args, "depth", MCP_DEFAULT_DEPTH); depth = clamp_mcp_depth(depth, "trace_call_path"); + /* Per-direction node budget for the BFS working set. The old fixed + * MCP_BFS_LIMIT silently truncated hub traces at 100 nodes with no + * signal; now the limit is a documented parameter and hitting it emits + * `truncated: true` (never a silent truncation — same policy as the + * depth clamp, #887). */ + int trace_limit = cbm_mcp_get_int_arg(args, "limit", MCP_BFS_LIMIT); + if (trace_limit < 1) { + trace_limit = 1; + } + if (trace_limit > MCP_BFS_LIMIT_MAX) { + trace_limit = MCP_BFS_LIMIT_MAX; + } bool risk_labels = cbm_mcp_get_bool_arg(args, "risk_labels"); bool include_tests = cbm_mcp_get_bool_arg(args, "include_tests"); @@ -4723,9 +5116,52 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { return not_indexed; } + /* Pagination: decode + validate the resume cursor (if any) against the + * current store generation and the canonical params. Errors teach the + * recovery action instead of silently restarting from page 1. */ + char generation[96]; + (void)cbm_store_generation(store, generation, sizeof(generation)); + char *cursor_arg = cbm_mcp_get_string_arg(args, "cursor"); + trace_cursor_t cur = {0}; + bool have_cursor = false; + if (cursor_arg && cursor_arg[0]) { + uint64_t qh = + trace_params_hash(project, func_name, direction ? direction : "both", mode, + cbm_mcp_get_int_arg(args, "depth", MCP_DEFAULT_DEPTH), include_tests, + cbm_mcp_get_int_arg(args, "limit", MCP_BFS_LIMIT)); + const char *cerr = trace_cursor_decode(cursor_arg, generation, qh, &cur); + if (cerr) { + free(cursor_arg); + free(func_name); + free(project); + free(direction); + free(mode); + free(param_name); + return cbm_mcp_text_result(cerr, true); + } + have_cursor = true; + } + free(cursor_arg); if (!direction) { direction = heap_strdup("both"); } + /* Teaching error: an unknown direction used to silently produce an empty + * trace (both leg flags false) — a field-eval agent burned four calls on + * "callers"/"callees" before falling back to Cypher. */ + if (strcmp(direction, "inbound") != 0 && strcmp(direction, "outbound") != 0 && + strcmp(direction, "both") != 0) { + char errbuf[CBM_SZ_256]; + snprintf(errbuf, sizeof(errbuf), + "invalid direction \"%s\" — use \"inbound\" (callers), \"outbound\" (callees), " + "or \"both\"", + direction); + free(func_name); + free(project); + free(direction); + free(mode); + free(param_name); + return cbm_mcp_text_result(errbuf, true); + } /* Find the node by name. If the bare-name lookup misses, fall back to * qualified_name so callers passing a fully-qualified identifier (which @@ -4784,9 +5220,11 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } /* Response encoding: TOON tables by default; format:"json" restores the - * legacy verbose per-hop objects. */ + * legacy verbose per-hop objects; format:"tree" = Phase-2 A/B candidate + * (qn-prefix-grouped rows). */ char *trace_format = cbm_mcp_get_string_arg(args, "format"); bool trace_legacy_json = trace_format && strcmp(trace_format, "json") == 0; + bool trace_tree = trace_format && strcmp(trace_format, "tree") == 0; free(trace_format); /* Edge types: explicit > mode-based > default */ @@ -4807,14 +5245,79 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { (void)sel; /* union across all same-name nodes — see bfs_union_same_name (#546) */ + /* Traverse with the SAFETY ceiling (not the page size): the recursive CTE + * enumerates the full depth-bounded reachable set regardless of LIMIT, so + * materializing up to MCP_BFS_LIMIT_MAX rows costs the same traversal — + * and gives exact totals plus the rows every later page needs. The page + * size (trace_limit) only bounds what THIS response emits. */ if (do_outbound) { bfs_union_same_name(store, nodes, node_count, "outbound", edge_types, edge_type_count, - depth, &tr_out); + depth, MCP_BFS_LIMIT_MAX, &tr_out); } if (do_inbound) { bfs_union_same_name(store, nodes, node_count, "inbound", edge_types, edge_type_count, depth, - &tr_in); + MCP_BFS_LIMIT_MAX, &tr_in); + } + + /* Page windows in canonical (hop,id) order. Legs drain in a fixed order + * (callees, then callers); a resume cursor starts its leg at the row + * after the watermark, and a page that finishes one leg with budget to + * spare continues into the next. */ + int out_start = 0; + int in_start = 0; + if (have_cursor) { + if (cur.leg == 'o') { + out_start = trace_watermark_index(&tr_out, cur.hop, cur.node_id); + } else { + out_start = tr_out.visited_count; /* callees leg already drained */ + in_start = trace_watermark_index(&tr_in, cur.hop, cur.node_id); + } + } + int budget = trace_limit; + int out_len = 0; + int in_len = 0; + if (do_outbound) { + out_len = tr_out.visited_count - out_start; + if (out_len > budget) { + out_len = budget; + } + budget -= out_len; } + if (do_inbound) { + in_len = tr_in.visited_count - in_start; + if (in_len > budget) { + in_len = budget; + } + } + bool out_more = do_outbound && out_start + out_len < tr_out.visited_count; + bool in_more = do_inbound && in_start + in_len < tr_in.visited_count; + char next_tok[192] = ""; + if (out_more || in_more) { + trace_cursor_t nc = {0}; + snprintf(nc.generation, sizeof(nc.generation), "%s", generation); + nc.qhash = trace_params_hash(project, func_name, direction, mode, depth, include_tests, + trace_limit); + if (out_more) { + nc.leg = 'o'; + nc.hop = tr_out.visited[out_start + out_len - 1].hop; + nc.node_id = tr_out.visited[out_start + out_len - 1].node.id; + } else { + nc.leg = 'i'; + nc.hop = tr_in.visited[in_start + in_len - 1].hop; + nc.node_id = tr_in.visited[in_start + in_len - 1].node.id; + } + trace_cursor_encode(&nc, next_tok, sizeof(next_tok)); + } + + /* Window views: visited offset + count; the full edges array stays + * attached so data_flow args resolve for boundary nodes whose incoming + * edge originated on an earlier page. */ + cbm_traverse_result_t view_out = tr_out; + view_out.visited += out_start; + view_out.visited_count = out_len; + cbm_traverse_result_t view_in = tr_in; + view_in.visited += in_start; + view_in.visited_count = in_len; char *json = NULL; if (!trace_legacy_json) { @@ -4825,11 +5328,33 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { if (mode) { cbm_toon_scalar_str(&sb, "mode", mode); } + /* Grouped tree is THE default; risk_labels/data_flow keep the flat + * table (extra columns) in the same tree syntax. */ + (void)trace_tree; + bool flat_trace = risk_labels || data_flow; if (do_outbound) { - bfs_to_toon_table(&sb, "callees", &tr_out, risk_labels, include_tests, data_flow); + cbm_toon_scalar_int(&sb, "callees_total", tr_out.visited_count); + if (flat_trace) { + bfs_to_toon_table(&sb, "callees", &view_out, risk_labels, include_tests, data_flow); + } else { + bfs_to_tree_table(&sb, "callees", &view_out, include_tests); + } } if (do_inbound) { - bfs_to_toon_table(&sb, "callers", &tr_in, risk_labels, include_tests, data_flow); + cbm_toon_scalar_int(&sb, "callers_total", tr_in.visited_count); + if (flat_trace) { + bfs_to_toon_table(&sb, "callers", &view_in, risk_labels, include_tests, data_flow); + } else { + bfs_to_tree_table(&sb, "callers", &view_in, include_tests); + } + } + if (next_tok[0]) { + cbm_toon_scalar_bool(&sb, "truncated", true); + cbm_toon_scalar_str(&sb, "next", next_tok); + cbm_toon_scalar_str(&sb, "hint", + "more rows exist — re-call with cursor set to 'next' and ALL " + "other arguments identical (no duplicates), or narrow with " + "depth/edge_types"); } json = cbm_sb_finish(&sb); } else { @@ -4843,14 +5368,20 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_str(doc, root, "mode", mode); } if (do_outbound) { + yyjson_mut_obj_add_int(doc, root, "callees_total", tr_out.visited_count); yyjson_mut_obj_add_val( doc, root, "callees", - bfs_to_json_array(doc, &tr_out, risk_labels, include_tests, data_flow)); + bfs_to_json_array(doc, &view_out, risk_labels, include_tests, data_flow)); } if (do_inbound) { + yyjson_mut_obj_add_int(doc, root, "callers_total", tr_in.visited_count); yyjson_mut_obj_add_val( doc, root, "callers", - bfs_to_json_array(doc, &tr_in, risk_labels, include_tests, data_flow)); + bfs_to_json_array(doc, &view_in, risk_labels, include_tests, data_flow)); + } + if (next_tok[0]) { + yyjson_mut_obj_add_bool(doc, root, "truncated", true); + yyjson_mut_obj_add_strcpy(doc, root, "next_cursor", next_tok); } /* Serialize BEFORE freeing traversal results (yyjson borrows strings) */ json = yy_doc_to_str(doc); @@ -5029,7 +5560,7 @@ static void try_artifact_bootstrap(const char *project_name, const char *repo_pa /* Cap on excluded dir paths listed in the response — keep it compact on large * repos (node_modules / vendor / etc. can produce many skip points). The full * count is still reported via "count" + "truncated". */ -enum { INDEX_EXCLUDED_DIR_CAP = 25 }; +enum { INDEX_EXCLUDED_DIR_CAP = 5 }; /* examples only — see INDEX_SKIPPED_FILE_CAP note */ /* Attach a compact summary of directory subtrees skipped during discovery (#411). * Shape: "excluded": {"dirs": [up to 25 rel-paths], "count": , "truncated": }. @@ -5056,7 +5587,12 @@ static void add_excluded_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, char /* Cap on per-file skips embedded in the JSON response — keep it compact on * large repos. The FULL, uncapped list always goes to the per-run logfile; * the JSON carries "count" + "truncated" so nothing is silently hidden. */ -enum { INDEX_SKIPPED_FILE_CAP = 50 }; +/* In-response coverage lists are EXAMPLES, not the record: the full uncapped + * lists live in the per-run logfile (path in the same response) and are + * queryable via index_status (scope_limit) / query_graph(graph="missed"). + * Five examples orient the agent; anything more duplicates the logfile into + * every index response (53 KB observed on a large repo). */ +enum { INDEX_SKIPPED_FILE_CAP = 5 }; /* Attach the by-design ignored-FILES summary (#963 "purposely not indexed"). * Individual files dropped by ignore rules — deliberate, not failures; whole @@ -5086,10 +5622,8 @@ static void add_not_indexed_files_summary(yyjson_mut_doc *doc, yyjson_mut_val *r yyjson_mut_obj_add_int(doc, ni, "count", total); yyjson_mut_obj_add_bool(doc, ni, "truncated", total > shown); yyjson_mut_obj_add_str(doc, ni, "note", - "Purposely not indexed — excluded BY DESIGN via " - "gitignore/.cbmignore/skip-lists (see each file's reason). Not an " - "error: change the ignore rules and re-index to include them. Whole " - "excluded subtrees are listed separately under \"excluded\"."); + "Excluded by design (gitignore/.cbmignore/skip-lists); examples only — " + "full list in 'logfile'."); yyjson_mut_obj_add_val(doc, root, "not_indexed_files", ni); } @@ -5181,12 +5715,9 @@ static void add_parse_partial_summary(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_obj_add_int(doc, pp, "count", partials); yyjson_mut_obj_add_bool(doc, pp, "truncated", partials > INDEX_SKIPPED_FILE_CAP); yyjson_mut_obj_add_str(doc, pp, "note", - "Best-effort signal, not a completeness guarantee: these files WERE " - "indexed, but constructs inside the listed line ranges (1-based) could " - "not be parsed and MAY be missing from the graph (tree-sitter error " - "recovery still salvages some). Prefer text search (grep) for those " - "regions. Files absent from this list are NOT guaranteed to be fully " - "indexed. Query the persisted signal via index_status."); + "Indexed, but constructs in these line ranges may be missing (best-" + "effort signal); examples only — full list via index_status or " + "'logfile'."); yyjson_mut_obj_add_val(doc, root, "parse_partial", pp); } diff --git a/src/store/store.c b/src/store/store.c index efd56f531..a9e86cb34 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -1226,6 +1226,51 @@ int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_ store_set_error_sqlite(s, "upsert_project"); return CBM_STORE_ERR; } + + /* Store generation for cursor staleness (pagination): db_uid is a random + * identity minted once per DB FILE (a full reindex publishes a fresh file + * via the writer, which carries no store_meta — the first upsert_project + * on the opened store seeds a NEW uid, so cursors minted against the old + * file can never validate against the rebuilt one, whose node ids all + * differ). mutation_gen increments on every project upsert — the choke + * point every index run (full, incremental, watcher) passes through. + * Created here rather than in the byte-level writer: adding a table to + * its hand-built sqlite_master is rootpage surgery for zero benefit. */ + (void)sqlite3_exec(s->db, + "CREATE TABLE IF NOT EXISTS store_meta (k TEXT PRIMARY KEY, v TEXT);" + "INSERT OR IGNORE INTO store_meta VALUES" + "('db_uid', lower(hex(randomblob(8))));" + "INSERT OR IGNORE INTO store_meta VALUES('mutation_gen','0');" + "UPDATE store_meta SET v = CAST(CAST(v AS INTEGER)+1 AS TEXT) " + "WHERE k='mutation_gen';", + NULL, NULL, NULL); + return CBM_STORE_OK; +} + +/* Opaque store generation for pagination cursors: "ug", + * or "legacy" when the DB predates store_meta (read-only opens never create + * it). A cursor whose embedded generation mismatches the store's current one + * is stale — the graph may have changed under it. */ +int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) { + if (!s || !s->db || !buf || bufsz == 0) { + return CBM_STORE_ERR; + } + snprintf(buf, bufsz, "legacy"); + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT (SELECT v FROM store_meta WHERE k='db_uid')," + " (SELECT v FROM store_meta WHERE k='mutation_gen');", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + return CBM_STORE_OK; /* pre-migration DB: stable "legacy" generation */ + } + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *uid = (const char *)sqlite3_column_text(stmt, 0); + const char *gen = (const char *)sqlite3_column_text(stmt, SKIP_ONE); + if (uid && gen) { + snprintf(buf, bufsz, "u%sg%s", uid, gen); + } + } + sqlite3_finalize(stmt); return CBM_STORE_OK; } @@ -3474,9 +3519,12 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear int limit = params->limit > 0 ? params->limit : CBM_DEFAULT_SEARCH_LIMIT; int offset = params->offset; const char *name_col = has_degree_filter ? "name" : "n.name"; + const char *id_col = has_degree_filter ? "id" : "n.id"; char order_limit[CBM_SZ_128]; - snprintf(order_limit, sizeof(order_limit), " ORDER BY %s LIMIT %d OFFSET %d", name_col, limit, - offset); + /* (name, id) is a unique total order — names are non-unique, and without + * the tie-break offset pages are not contractually stable across calls. */ + snprintf(order_limit, sizeof(order_limit), " ORDER BY %s, %s LIMIT %d OFFSET %d", name_col, + id_col, limit, offset); strncat(sql, order_limit, sizeof(sql) - strlen(sql) - 1); /* Execute count query */ @@ -3565,19 +3613,34 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho int visited_count, const char *types_clause, const char **edge_types, int edge_type_count, cbm_edge_info_t **out_edges, int *out_edge_count) { - /* Build ID set: root + all visited */ - char id_set[CBM_SZ_4K]; - int ilen = snprintf(id_set, sizeof(id_set), "%lld", (long long)start_id); - if (ilen >= (int)sizeof(id_set)) { - ilen = (int)sizeof(id_set) - SKIP_ONE; + *out_edges = NULL; + *out_edge_count = 0; + + /* Visited-ID set via a per-connection TEMP table. The previous approach + * interpolated the ids into a fixed 4KB SQL string: past ~1000 visited + * nodes the list was cut MID-NUMBER, the SQL failed to prepare, and every + * trace edge silently vanished (and a luckier cut could match an + * unrelated node). TEMP tables live in the connection's temp db, so this + * works on read-only query connections too. */ + if (sqlite3_exec(s->db, + "CREATE TEMP TABLE IF NOT EXISTS bfs_ids (id INTEGER PRIMARY KEY);" + "DELETE FROM bfs_ids;", + NULL, NULL, NULL) != SQLITE_OK) { + return CBM_STORE_OK; /* best-effort: nodes without edges beat an error */ + } + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_ids(id) VALUES (?1)", CBM_NOT_FOUND, + &ins, NULL) != SQLITE_OK) { + return CBM_STORE_OK; } + sqlite3_bind_int64(ins, SKIP_ONE, start_id); + (void)sqlite3_step(ins); for (int i = 0; i < visited_count; i++) { - ilen += snprintf(id_set + ilen, sizeof(id_set) - (size_t)ilen, ",%lld", - (long long)visited[i].node.id); - if (ilen >= (int)sizeof(id_set)) { - ilen = (int)sizeof(id_set) - SKIP_ONE; - } + sqlite3_reset(ins); + sqlite3_bind_int64(ins, SKIP_ONE, visited[i].node.id); + (void)sqlite3_step(ins); } + sqlite3_finalize(ins); char edge_sql[ST_SQL_BUF]; snprintf(edge_sql, sizeof(edge_sql), @@ -3585,9 +3648,10 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho "FROM edges e " "JOIN nodes n1 ON n1.id = e.source_id " "JOIN nodes n2 ON n2.id = e.target_id " - "WHERE e.source_id IN (%s) AND e.target_id IN (%s) " + "WHERE e.source_id IN (SELECT id FROM bfs_ids) " + "AND e.target_id IN (SELECT id FROM bfs_ids) " "AND e.type IN (%s)", - id_set, id_set, types_clause); + types_clause); sqlite3_stmt *estmt = NULL; int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL); @@ -3708,7 +3772,9 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const "JOIN nodes n ON n.id = bfs.node_id " "WHERE bfs.hop > 0 " /* exclude root at hop 0 (self via a loop still appears) */ "GROUP BY n.id " - "ORDER BY hop " + /* (hop, id) is a unique total order — deterministic pagination + * watermarks and reproducible trace output depend on it. */ + "ORDER BY hop, n.id " "LIMIT %d;", (long long)start_id, next_id, join_cond, types_clause, max_depth, max_results); diff --git a/src/store/store.h b/src/store/store.h index a07facbc4..d088139ea 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -266,6 +266,11 @@ int cbm_store_checkpoint(cbm_store_t *s); * connection, in bytes; -1 = unlimited (SQLite default / pre-fix). */ int64_t cbm_store_journal_size_limit(cbm_store_t *s); +/* Opaque store generation for pagination-cursor staleness detection: + * "ug" — db_uid is minted per DB file, mutation_gen + * bumps on every index run. "legacy" for DBs predating store_meta. */ +int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz); + /* Resolve the mmap_size pragma value applied to on-disk stores from the * CBM_SQLITE_MMAP_SIZE environment variable. Defaults to 67108864 (64 MB) * when the variable is unset, malformed, or partially numeric. Negative diff --git a/tests/test_index_resilience.c b/tests/test_index_resilience.c index 29d316942..8d8a601a6 100644 --- a/tests/test_index_resilience.c +++ b/tests/test_index_resilience.c @@ -359,7 +359,9 @@ TEST(index_parse_partial_reported) { ASSERT_TRUE(found_split); const char *note = yyjson_get_str(yyjson_obj_get(pp, "note")); ASSERT_NOT_NULL(note); - ASSERT_NOT_NULL(strstr(note, "guarantee")); + /* Lean-note contract: best-effort framing + pointer to the full record. */ + ASSERT_NOT_NULL(strstr(note, "best-effort")); + ASSERT_NOT_NULL(strstr(note, "logfile")); /* Logfile lists it under the distinct phase. */ const char *logfile = yyjson_get_str(yyjson_obj_get(sc, "logfile")); @@ -591,7 +593,9 @@ TEST(index_not_indexed_by_design_reported) { ASSERT_TRUE(found_secret); const char *note = yyjson_get_str(yyjson_obj_get(ni, "note")); ASSERT_NOT_NULL(note); - ASSERT_NOT_NULL(strstr(note, "BY DESIGN")); + /* Lean-note contract: deliberate-exclusion framing + pointer to the record. */ + ASSERT_NOT_NULL(strstr(note, "by design")); + ASSERT_NOT_NULL(strstr(note, "logfile")); /* The gitignored DIR is in the excluded subtrees list. */ yyjson_val *excluded = yyjson_obj_get(sc, "excluded"); diff --git a/tests/test_integration.c b/tests/test_integration.c index b81ac378b..a63f9e5d4 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -580,6 +580,59 @@ TEST(integ_store_bfs_traversal) { PASS(); } +/* bfs_collect_edges built its visited-ID set into a fixed 4KB string: past + * ~340-1100 visited nodes (id-width dependent) the id list was SILENTLY cut, + * so trace edges (and data_flow args) vanished — and a partially-written id + * could even match an UNRELATED node, admitting wrong edges. GUARD: a star of + * 1200 callers (id string ≈ 4.6KB) must surface every edge. RED on the fixed + * buffer, GREEN with the temp-table join. */ +TEST(store_bfs_edges_survive_large_visited_set) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "star", "/tmp/star"), CBM_STORE_OK); + + cbm_node_t hub = {0}; + hub.project = "star"; + hub.label = "Function"; + hub.name = "hub"; + hub.qualified_name = "star.hub"; + hub.file_path = "hub.c"; + int64_t hub_id = cbm_store_upsert_node(s, &hub); + ASSERT_GT(hub_id, 0); + + enum { SPOKES = 1200 }; + for (int i = 0; i < SPOKES; i++) { + char nm[32]; + char qn[64]; + snprintf(nm, sizeof(nm), "caller_%04d", i); + snprintf(qn, sizeof(qn), "star.caller_%04d", i); + cbm_node_t sp = {0}; + sp.project = "star"; + sp.label = "Function"; + sp.name = nm; + sp.qualified_name = qn; + sp.file_path = "spokes.c"; + int64_t sid = cbm_store_upsert_node(s, &sp); + ASSERT_GT(sid, 0); + cbm_edge_t e = {0}; + e.project = "star"; + e.source_id = sid; + e.target_id = hub_id; + e.type = "CALLS"; + ASSERT_GT(cbm_store_insert_edge(s, &e), 0); /* returns the edge id */ + } + + cbm_traverse_result_t tr = {0}; + ASSERT_EQ(cbm_store_bfs(s, hub_id, "inbound", NULL, 0, 1, SPOKES + 10, &tr), CBM_STORE_OK); + ASSERT_EQ(tr.visited_count, SPOKES); + /* Every caller->hub edge must be collected — none silently dropped. */ + ASSERT_EQ(tr.edge_count, SPOKES); + + cbm_store_traverse_free(&tr); + cbm_store_close(s); + PASS(); +} + /* #411: index_repository silently drops entire subtrees with no record. * Moderate/fast mode applies FAST_SKIP_DIRS (tools/scripts/bin/docs/...) and ALL * modes apply ALWAYS_SKIP_DIRS (node_modules/...), so files are excluded from the @@ -667,6 +720,7 @@ SUITE(integration) { RUN_TEST(integ_store_search_by_degree); RUN_TEST(integ_store_find_by_file); RUN_TEST(integ_store_bfs_traversal); + RUN_TEST(store_bfs_edges_survive_large_visited_set); /* Pipeline API tests (no db needed) */ RUN_TEST(integ_pipeline_fqn_compute); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4e1d5eeb3..0fdfa5e20 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1110,8 +1110,8 @@ TEST(tool_search_graph_includes_node_properties) { ASSERT_NOT_NULL(strstr(resp, "\"structuredContent\":{\"text\":")); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "results[")); /* TOON table header */ - ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out}")); + ASSERT_NOT_NULL(strstr(inner, "results:")); /* TOON table header */ + ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out;")); ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); ASSERT_NULL(strstr(inner, "func HandleRequest")); /* signature not spilled */ ASSERT_NULL(strstr(inner, "is_exported")); @@ -1127,7 +1127,7 @@ TEST(tool_search_graph_includes_node_properties) { ASSERT_NOT_NULL(resp); inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out,signature}")); + ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out signature;")); ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); free(inner); free(resp); @@ -1185,7 +1185,7 @@ TEST(tool_output_byte_budgets) { ASSERT_NOT_NULL(resp); inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "callees[")); + ASSERT_NOT_NULL(strstr(inner, "callees:")); ASSERT_LT((int)strlen(inner), 800); free(inner); free(resp); @@ -1242,6 +1242,187 @@ TEST(tool_search_graph_toon_never_leaks_internal_fields) { PASS(); } +TEST(tool_lean_defaults_schema_and_status) { + /* GUARDS for the lean-default contract (TOON round 2): + * 1. get_graph_schema must not advertise the blocked internal fields + * (fp/sp/bt) — the server refuses to emit them, so listing them in the + * schema invited agents to request fields they can never get. + * 2. index_status omits the git context block unless verbose:true — the + * worktree/shadow path variants only matter when debugging where an + * index lives. */ + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + + cbm_node_t n = {0}; + n.project = "test-project"; + n.label = "Function"; + n.name = "schemaCarrier"; + n.qualified_name = "test-project.src.schemaCarrier"; + n.file_path = "src/sc.go"; + n.start_line = 1; + n.end_line = 2; + n.properties_json = "{\"fp\":\"x\",\"sp\":\"y\",\"bt\":\"z\",\"complexity\":3}"; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"get_graph_schema\"," + "\"arguments\":{\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "Function")); /* non-vacuous: label present */ + ASSERT_NOT_NULL(strstr(inner, "complexity")); /* obtainable property listed */ + ASSERT_NULL(strstr(inner, "\"fp\"")); /* blocked fields not advertised */ + ASSERT_NULL(strstr(inner, "\"sp\"")); + ASSERT_NULL(strstr(inner, "\"bt\"")); + free(inner); + free(resp); + + /* index_status: no git block by default... */ + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":49,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"status\"")); + ASSERT_NULL(strstr(inner, "\"git\"")); + free(inner); + free(resp); + + /* ...and present with verbose:true. */ + resp = cbm_mcp_server_handle(srv, + "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"git\"")); + free(inner); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + +/* ── Tool-output regression suite (gating) ────────────────────────── + * Context-explosion detector: flags the measured smells that re-introduce + * token bloat into default outputs, independent of any specific tool: + * 1. blocked internal fields (fp/sp/bt) appearing anywhere; + * 2. repeated-key JSON envelopes — the same key emitted per row instead of + * a header-once table (the un-TOONed enumeration smell; detect_changes + * shipped 4,787x3 of these = 416KB); + * 3. embedded prose notes/hints beyond one line (~220 chars) — long prose + * belongs in tool descriptions or docs, not repeated per response. + * Returns NULL when clean, else a static description of the violation. */ +static const char *output_explosion_smell(const char *inner) { + static const char *row_keys[] = { + "\"name\":", "\"label\":", "\"file\":", "\"path\":", "\"qualified_name\":", "\"qn\":"}; + if (strstr(inner, "\"fp\":") || strstr(inner, "\"sp\":") || strstr(inner, "\"bt\":")) { + return "blocked internal field (fp/sp/bt) leaked into output"; + } + for (size_t k = 0; k < sizeof(row_keys) / sizeof(row_keys[0]); k++) { + int n = 0; + for (const char *p = strstr(inner, row_keys[k]); p && n <= 32; + p = strstr(p + 1, row_keys[k])) { + n++; + } + if (n > 32) { + return "repeated-key envelope (>32x same JSON key) — emit a header-once table"; + } + } + for (const char *p = strstr(inner, "\"note\":\""); p; p = strstr(p + 1, "\"note\":\"")) { + const char *end = strchr(p + 9, '"'); + while (end && end[-1] == '\\') { + end = strchr(end + 1, '"'); + } + if (end && end - (p + 9) > 220) { + return "embedded note exceeds one line (~220 chars)"; + } + } + return NULL; +} + +/* Run one tool call on the fixture server, apply the explosion detector and + * an absolute byte ceiling, and require a semantic-floor marker so trimming + * can never hollow the response out either. */ +static const char *check_tool_output(cbm_mcp_server_t *srv, const char *req, int ceiling, + const char *floor_marker) { + char *resp = cbm_mcp_server_handle(srv, req); + if (!resp) { + return "no response"; + } + char *inner = extract_text_content(resp); + free(resp); + if (!inner) { + return "no text content"; + } + static char why[256]; + const char *smell = output_explosion_smell(inner); + if (smell) { + snprintf(why, sizeof(why), "%s", smell); + free(inner); + return why; + } + if ((int)strlen(inner) >= ceiling) { + snprintf(why, sizeof(why), "output %d B >= ceiling %d B", (int)strlen(inner), ceiling); + free(inner); + return why; + } + if (floor_marker && !strstr(inner, floor_marker)) { + snprintf(why, sizeof(why), "semantic floor missing: %s", floor_marker); + free(inner); + return why; + } + free(inner); + return NULL; +} + +TEST(tool_output_regression_gate) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + struct { + const char *req; + int ceiling; + const char *floor; + } cases[] = { + {"{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\"," + "\"name_pattern\":\".*\",\"limit\":50}}}", + 6000, "results:"}, + {"{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_graph_schema\",\"arguments\":{\"project\":\"test-project\"}}}", + 6000, "node_labels"}, + {"{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"index_status\",\"arguments\":{\"project\":\"test-project\"}}}", + 7000, "\"status\""}, + {"{\"jsonrpc\":\"2.0\",\"id\":73,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"test-project\"," + "\"function_name\":\"HandleRequest\",\"direction\":\"both\"}}}", + 1500, "callees:"}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const char *why = check_tool_output(srv, cases[i].req, cases[i].ceiling, cases[i].floor); + if (why) { + char msg[320]; + snprintf(msg, sizeof(msg), "case %d: %s", (int)i, why); + FAIL(msg); + } + } + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + TEST(tool_search_graph_query_honors_file_pattern_issue552) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); @@ -1558,14 +1739,17 @@ TEST(tool_check_index_coverage_surfaces_lookup_errors) { } TEST(tool_index_status_includes_git_metadata) { + /* The git context block moved behind verbose:true (lean-default contract, + * TOON round 2) — this test pins the verbose path's content; the default- + * omission guard lives in tool_lean_defaults_schema_and_status. */ char tmp[256]; cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); ASSERT_NOT_NULL(srv); - char *resp = - cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_status\"," - "\"arguments\":{\"project\":\"test-project\"}}}"); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_status\"," + "\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}"); ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); @@ -1658,6 +1842,223 @@ TEST(tool_trace_call_path_ambiguous) { PASS(); } +/* Multi-seed union hop semantics: bfs_union_same_name deduped visited nodes + * keep-FIRST-seen, so a node reached at hop 2 from the first seed kept hop 2 + * even when the second seed reaches it at hop 1. hop feeds risk_labels and + * (soon) pagination watermarks — it must be the MINIMUM across seeds, matching + * the single-BFS MIN(hop) semantics (#797). */ +TEST(tool_trace_union_records_min_hop_across_seeds) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "dualproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/dual"); + + /* One real definition + one body-less stub (start==end) — the #546/#650 + * shape pick_resolved_node resolves WITHOUT ambiguity while + * bfs_union_same_name still traverses both. Seed A (real def, lower id, + * traversed first) reaches tgt only via mid (hop 2); the stub seed B + * reaches tgt directly (hop 1). */ + cbm_node_t sa = {.project = proj, + .label = "Function", + .name = "dual", + .qualified_name = "dualproj.a.dual", + .file_path = "a.c", + .start_line = 1, + .end_line = 50}; + cbm_node_t sb = {.project = proj, + .label = "Function", + .name = "dual", + .qualified_name = "dualproj.b.dual", + .file_path = "b.d.ts", + .start_line = 1, + .end_line = 1}; + cbm_node_t mid = {.project = proj, + .label = "Function", + .name = "mid", + .qualified_name = "dualproj.c.mid", + .file_path = "c.c", + .start_line = 1, + .end_line = 5}; + cbm_node_t tgt = {.project = proj, + .label = "Function", + .name = "tgt", + .qualified_name = "dualproj.c.tgt", + .file_path = "c.c", + .start_line = 10, + .end_line = 15}; + int64_t ida = cbm_store_upsert_node(st, &sa); + int64_t idb = cbm_store_upsert_node(st, &sb); + int64_t idm = cbm_store_upsert_node(st, &mid); + int64_t idt = cbm_store_upsert_node(st, &tgt); + ASSERT_GT(ida, 0); + ASSERT_GT(idb, 0); + ASSERT_GT(idm, 0); + ASSERT_GT(idt, 0); + cbm_edge_t e1 = {.project = proj, .source_id = ida, .target_id = idm, .type = "CALLS"}; + cbm_edge_t e2 = {.project = proj, .source_id = idm, .target_id = idt, .type = "CALLS"}; + cbm_edge_t e3 = {.project = proj, .source_id = idb, .target_id = idt, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e1), 0); + ASSERT_GT(cbm_store_insert_edge(st, &e2), 0); + ASSERT_GT(cbm_store_insert_edge(st, &e3), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"trace_call_path\"," + "\"arguments\":{\"function_name\":\"dual\",\"project\":\"dualproj\"," + "\"direction\":\"outbound\",\"depth\":3}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + /* tgt is one hop from seed B — the union must record hop 1, not seed A's 2. */ + ASSERT_NOT_NULL(strstr(inner, " tgt 1")); + ASSERT_NULL(strstr(inner, " tgt 2")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Exactly-once trace pagination: 12 callees paged at limit=5 must yield + * 5+5+2 rows with every callee appearing on exactly one page, exact totals + * on every page, and a final page without a cursor. Stale and mismatched + * cursors must fail with teaching errors, never silently restart. */ +TEST(tool_trace_pagination_exactly_once) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "pageproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/page"); + + cbm_node_t hub = {.project = proj, + .label = "Function", + .name = "hub", + .qualified_name = "pageproj.h.hub", + .file_path = "h.c", + .start_line = 1, + .end_line = 9}; + int64_t hid = cbm_store_upsert_node(st, &hub); + ASSERT_GT(hid, 0); + enum { CALLEES = 12 }; + for (int i = 0; i < CALLEES; i++) { + char nm[16]; + char qn[48]; + snprintf(nm, sizeof(nm), "c%02d", i); + snprintf(qn, sizeof(qn), "pageproj.m.c%02d", i); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = nm, + .qualified_name = qn, + .file_path = "m.c", + .start_line = 1, + .end_line = 3}; + int64_t nid = cbm_store_upsert_node(st, &n); + ASSERT_GT(nid, 0); + cbm_edge_t e = {.project = proj, .source_id = hid, .target_id = nid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e), 0); + } + + char pages[3][4096]; + char tok[192] = ""; + int npages = 0; + for (; npages < 3; npages++) { + char req[640]; + if (tok[0]) { + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5," + "\"cursor\":\"%s\"}}}", + tok); + } else { + snprintf(req, sizeof(req), + "{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5}}}"); + } + char *resp = cbm_mcp_server_handle(srv, req); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + snprintf(pages[npages], sizeof(pages[npages]), "%s", inner); + ASSERT_NOT_NULL(strstr(inner, "callees_total: 12")); /* exact total, every page */ + const char *nx = strstr(inner, "next: "); + if (nx) { + const char *e = strchr(nx + 6, '\n'); + size_t tl = e ? (size_t)(e - (nx + 6)) : strlen(nx + 6); + ASSERT_TRUE(tl < sizeof(tok)); + memcpy(tok, nx + 6, tl); + tok[tl] = '\0'; + } else { + tok[0] = '\0'; + } + free(inner); + if (!tok[0]) { + npages++; + break; + } + } + ASSERT_EQ(npages, 3); /* 5 + 5 + 2 */ + /* Exactly-once: every callee appears on exactly ONE page. */ + for (int i = 0; i < CALLEES; i++) { + char qn[48]; + snprintf(qn, sizeof(qn), " c%02d 1\n", i); + int seen = 0; + for (int p = 0; p < 3; p++) { + if (strstr(pages[p], qn)) { + seen++; + } + } + ASSERT_EQ(seen, 1); + } + /* Final page carries no cursor. */ + ASSERT_NULL(strstr(pages[2], "next: ")); + + /* Params mismatch: replay a page-2-era cursor with a different depth. */ + const char *nx1 = strstr(pages[0], "next: "); + ASSERT_NOT_NULL(nx1); + char tok1[192]; + const char *e1 = strchr(nx1 + 6, '\n'); + size_t tl1 = e1 ? (size_t)(e1 - (nx1 + 6)) : strlen(nx1 + 6); + memcpy(tok1, nx1 + 6, tl1); + tok1[tl1] = '\0'; + char req2[640]; + snprintf(req2, sizeof(req2), + "{\"jsonrpc\":\"2.0\",\"id\":81,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,\"depth\":2," + "\"cursor\":\"%s\"}}}", + tok1); + char *resp = cbm_mcp_server_handle(srv, req2); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "cursor_params_mismatch")); + free(inner); + + /* Stale: an index run (upsert_project bumps the generation) invalidates + * outstanding cursors with a loud, actionable error. */ + cbm_store_upsert_project(st, proj, "/tmp/page"); + snprintf(req2, sizeof(req2), + "{\"jsonrpc\":\"2.0\",\"id\":82,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\"," + "\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5," + "\"cursor\":\"%s\"}}}", + tok1); + resp = cbm_mcp_server_handle(srv, req2); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "stale_cursor")); + free(inner); + + cbm_mcp_server_free(srv); + PASS(); +} + /* Regression: when same-named nodes differ in rank, trace must pick the real * definition (callable, larger body) — NOT nodes[0]. The Module is inserted * first; if trace took nodes[0] the outbound trace would be empty. */ @@ -1985,7 +2386,7 @@ TEST(tool_get_architecture_emits_populated_sections) { * those existed before #281. The "entry_points" array only appears * when cbm_store_get_architecture is actually called and its result * is serialized — which is exactly what #281 wires up. */ - ASSERT_NOT_NULL(strstr(inner, "entry_points[")); + ASSERT_NOT_NULL(strstr(inner, "entry_points:")); ASSERT_NOT_NULL(strstr(inner, "main")); free(inner); @@ -2038,8 +2439,8 @@ TEST(tool_get_architecture_overview_compact_subset_pr560) { ASSERT_NOT_NULL(resp_all); char *inner_all = extract_text_content(resp_all); ASSERT_NOT_NULL(inner_all); - ASSERT_NOT_NULL(strstr(inner_all, "entry_points[")); - ASSERT_NOT_NULL(strstr(inner_all, "file_tree[")); + ASSERT_NOT_NULL(strstr(inner_all, "entry_points:")); + ASSERT_NOT_NULL(strstr(inner_all, "file_tree:")); free(inner_all); free(resp_all); @@ -2052,9 +2453,9 @@ TEST(tool_get_architecture_overview_compact_subset_pr560) { ASSERT_NOT_NULL(resp); char *inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "entry_points[")); - ASSERT_NOT_NULL(strstr(inner, "node_labels[")); - ASSERT_NULL(strstr(inner, "file_tree[")); + ASSERT_NOT_NULL(strstr(inner, "entry_points:")); + ASSERT_NOT_NULL(strstr(inner, "node_labels:")); + ASSERT_NULL(strstr(inner, "file_tree:")); free(inner); free(resp); @@ -2135,7 +2536,7 @@ TEST(tool_get_architecture_accepts_project_name_alias_issue640) { /* RED before the alias: inner is the "project not found" error. * GREEN after: the alias resolves and architecture sections surface. */ ASSERT_NULL(strstr(inner, "project not found")); - ASSERT_NOT_NULL(strstr(inner, "entry_points[")); + ASSERT_NOT_NULL(strstr(inner, "entry_points:")); free(inner); free(resp); @@ -6404,6 +6805,8 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_basic); RUN_TEST(tool_search_graph_includes_node_properties); RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields); + RUN_TEST(tool_lean_defaults_schema_and_status); + RUN_TEST(tool_output_regression_gate); RUN_TEST(tool_output_byte_budgets); RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); RUN_TEST(mcp_resource_discovery_methods_return_empty_lists); @@ -6420,6 +6823,8 @@ SUITE(mcp) { RUN_TEST(tool_trace_call_path_not_found); RUN_TEST(tool_trace_missing_function_name); RUN_TEST(tool_trace_call_path_ambiguous); + RUN_TEST(tool_trace_union_records_min_hop_across_seeds); + RUN_TEST(tool_trace_pagination_exactly_once); RUN_TEST(tool_trace_call_path_prefers_definition); RUN_TEST(tool_trace_call_path_depth_clamped); RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned); diff --git a/tests/test_store_pragmas.c b/tests/test_store_pragmas.c index 5aff16235..0d093589c 100644 --- a/tests/test_store_pragmas.c +++ b/tests/test_store_pragmas.c @@ -113,6 +113,35 @@ TEST(journal_size_limit_bounds_wal_issue1083) { PASS(); } +/* Pagination-cursor generation: minted per DB file, bumped per index run. + * Same store + reads only -> stable; upsert_project (every index run's choke + * point) -> changes; two distinct DB files can never share a generation even + * at the same counter value (random db_uid). */ +TEST(store_generation_tracks_mutations) { + char g1[128]; + char g2[128]; + char g3[128]; + cbm_store_t *a = cbm_store_open_memory(); + ASSERT(a != NULL); + ASSERT_EQ(cbm_store_upsert_project(a, "p", "/tmp/p"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_generation(a, g1, sizeof(g1)), CBM_STORE_OK); + ASSERT(strncmp(g1, "u", 1) == 0); /* seeded, not legacy */ + ASSERT_EQ(cbm_store_generation(a, g2, sizeof(g2)), CBM_STORE_OK); + ASSERT(strcmp(g1, g2) == 0); /* reads are stable */ + ASSERT_EQ(cbm_store_upsert_project(a, "p", "/tmp/p"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_generation(a, g3, sizeof(g3)), CBM_STORE_OK); + ASSERT(strcmp(g1, g3) != 0); /* index run bumps */ + + cbm_store_t *b = cbm_store_open_memory(); + ASSERT(b != NULL); + ASSERT_EQ(cbm_store_upsert_project(b, "p", "/tmp/p"), CBM_STORE_OK); + char gb[128]; + ASSERT_EQ(cbm_store_generation(b, gb, sizeof(gb)), CBM_STORE_OK); + ASSERT(strcmp(g1, gb) != 0); /* distinct DBs never alias (random uid) */ + cbm_store_close(a); + cbm_store_close(b); + PASS(); +} /* #896: a row-scan that dies mid-stream (SQLITE_CORRUPT) must surface a * loud store error, not masquerade as a clean end of results. Counts are @@ -134,8 +163,7 @@ TEST(corrupt_page_scan_returns_error_not_truncation) { char qn[256]; snprintf(name, sizeof(name), "corrupt_probe_fn_%04d", i); snprintf(qn, sizeof(qn), - "corr.some.rather.long.module.path.to.fill.table.pages.%s_padding_padding", - name); + "corr.some.rather.long.module.path.to.fill.table.pages.%s_padding_padding", name); cbm_node_t n = {.project = "corr", .label = "Function", .name = name, @@ -174,8 +202,8 @@ TEST(corrupt_page_scan_returns_error_not_truncation) { cbm_store_t *s2 = cbm_store_open_path(db_path); ASSERT_NOT_NULL(s2); /* The scan must CROSS the corrupt band: request every row. */ - cbm_search_params_t all_params = {.project = "corr", .label = "Function", - .limit = CORRUPT_NODES}; + cbm_search_params_t all_params = { + .project = "corr", .label = "Function", .limit = CORRUPT_NODES}; cbm_search_output_t out2 = {0}; int rc_search = cbm_store_search(s2, &all_params, &out2); if (rc_search == CBM_STORE_OK && out2.count == CORRUPT_NODES) { @@ -207,6 +235,7 @@ TEST(corrupt_page_scan_returns_error_not_truncation) { SUITE(store_pragmas) { RUN_TEST(journal_size_limit_bounds_wal_issue1083); + RUN_TEST(store_generation_tracks_mutations); RUN_TEST(corrupt_page_scan_returns_error_not_truncation); RUN_TEST(mmap_size_default_when_unset); RUN_TEST(mmap_size_zero_disables_mmap); From 3f5ae18852f5b578edcb9fe364b3423ea22748be Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 17 Jul 2026 22:58:22 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(mcp):=20complete=20the=20tree-format?= =?UTF-8?q?=20migration=20=E2=80=94=20TOON=20pruned,=20json=20=3D=20string?= =?UTF-8?q?ified=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cbm_toon_* renamed to cbm_tree_* (the emitters have emitted tree syntax since the format flip; the name now matches). - format enums reduced to [tree, json], default tree; dead tree/toon flags removed; tool RESPONSE descriptions rewritten for the grouped-tree contract (group prefix printed once, full qn = prefix + '.' + name). - format:'json' is now the json-stringified tree: the SAME grouped model serialized as JSON — {total, cols, groups:[{qn_prefix, file, rows:[[column-ordered cells]]}], has_more} for search, per-leg {cols, groups} for trace (risk/args columns follow the flags), query_graph's columns+rows shape already complied. Per-row key envelopes and the legacy property blob are gone (blob-never-spills pinned by test); requested add columns in both encodings. - include_connected keeps the per-node-object shape (nested neighbors) — the one remaining legacy consumer, conversion tracked. - legacy verbose emitters deleted (bfs_to_json_array). Full suite 6,367 passed / 0 failed; lint clean. Signed-off-by: Martin Vogel --- src/mcp/compact_out.c | 24 +- src/mcp/compact_out.h | 20 +- src/mcp/mcp.c | 628 +++++++++++++++++++++++++----------------- tests/test_mcp.c | 16 +- 4 files changed, 406 insertions(+), 282 deletions(-) diff --git a/src/mcp/compact_out.c b/src/mcp/compact_out.c index 729270225..64cb2e186 100644 --- a/src/mcp/compact_out.c +++ b/src/mcp/compact_out.c @@ -1,4 +1,4 @@ -/* compact_out.c — TOON emission helpers. See compact_out.h for the contract. */ +/* compact_out.c — tree-format emission helpers. See compact_out.h for the contract. */ #include "mcp/compact_out.h" #include @@ -72,7 +72,7 @@ void cbm_sb_free(cbm_sb_t *sb) { cbm_sb_init(sb); } -/* ── TOON quoting ───────────────────────────────────────────────── */ +/* ── Tree quoting ────────────────────────────────────────────────── */ /* True when `s` parses as an integer or real literal (sign, digits, one dot). */ static bool looks_numeric(const char *s) { @@ -153,14 +153,14 @@ static void append_value(cbm_sb_t *sb, const char *s) { /* ── Scalars ────────────────────────────────────────────────────── */ -void cbm_toon_scalar_str(cbm_sb_t *sb, const char *key, const char *val) { +void cbm_tree_scalar_str(cbm_sb_t *sb, const char *key, const char *val) { cbm_sb_append(sb, key); cbm_sb_append_n(sb, ": ", 2); append_value(sb, val); cbm_sb_append_n(sb, "\n", 1); } -void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v) { +void cbm_tree_scalar_int(cbm_sb_t *sb, const char *key, long long v) { char num[32]; snprintf(num, sizeof(num), "%lld", v); cbm_sb_append(sb, key); @@ -169,7 +169,7 @@ void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v) { cbm_sb_append_n(sb, "\n", 1); } -void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v) { +void cbm_tree_scalar_bool(cbm_sb_t *sb, const char *key, bool v) { cbm_sb_append(sb, key); cbm_sb_append_n(sb, ": ", 2); cbm_sb_append(sb, v ? "true" : "false"); @@ -180,7 +180,7 @@ void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v) { /* Tree-syntax table header: `key: N (cols: a b c)` — count first (agents * read scale before rows), column names once, rows indented beneath. */ -void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols, +void cbm_tree_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols, int ncols) { char num[32]; snprintf(num, sizeof(num), ": %d (cols:", n); @@ -193,18 +193,18 @@ void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *con cbm_sb_append_n(sb, ")\n", 2); } -void cbm_toon_row_begin(cbm_sb_t *sb) { +void cbm_tree_row_begin(cbm_sb_t *sb) { cbm_sb_append_n(sb, " ", 2); } -void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first) { +void cbm_tree_cell_str(cbm_sb_t *sb, const char *val, bool first) { if (!first) { cbm_sb_append_n(sb, " ", 1); } append_value(sb, val ? val : ""); } -void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first) { +void cbm_tree_cell_int(cbm_sb_t *sb, long long v, bool first) { char num[32]; snprintf(num, sizeof(num), "%lld", v); if (!first) { @@ -213,7 +213,7 @@ void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first) { cbm_sb_append(sb, num); } -void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first) { +void cbm_tree_cell_real(cbm_sb_t *sb, double v, bool first) { char num[48]; snprintf(num, sizeof(num), "%.4g", v); if (!first) { @@ -222,13 +222,13 @@ void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first) { cbm_sb_append(sb, num); } -void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first) { +void cbm_tree_cell_bool(cbm_sb_t *sb, bool v, bool first) { if (!first) { cbm_sb_append_n(sb, " ", 1); } cbm_sb_append(sb, v ? "true" : "false"); } -void cbm_toon_row_end(cbm_sb_t *sb) { +void cbm_tree_row_end(cbm_sb_t *sb) { cbm_sb_append_n(sb, "\n", 1); } diff --git a/src/mcp/compact_out.h b/src/mcp/compact_out.h index 9d9fdedda..7ebb404b1 100644 --- a/src/mcp/compact_out.h +++ b/src/mcp/compact_out.h @@ -34,21 +34,21 @@ char *cbm_sb_finish(cbm_sb_t *sb); void cbm_sb_free(cbm_sb_t *sb); /* `key: value` scalar lines (top-level, no indent). */ -void cbm_toon_scalar_str(cbm_sb_t *sb, const char *key, const char *val); -void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v); -void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v); +void cbm_tree_scalar_str(cbm_sb_t *sb, const char *key, const char *val); +void cbm_tree_scalar_int(cbm_sb_t *sb, const char *key, long long v); +void cbm_tree_scalar_bool(cbm_sb_t *sb, const char *key, bool v); /* `key[n]{col1,col2,...}:` table header; rows follow at 2-space indent. */ -void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols, +void cbm_tree_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols, int ncols); /* Row cells: call row_begin, then cell_* per column (first=true for the * first cell), then row_end. Empty/NULL strings emit as empty cells. */ -void cbm_toon_row_begin(cbm_sb_t *sb); -void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first); -void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first); -void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first); -void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first); -void cbm_toon_row_end(cbm_sb_t *sb); +void cbm_tree_row_begin(cbm_sb_t *sb); +void cbm_tree_cell_str(cbm_sb_t *sb, const char *val, bool first); +void cbm_tree_cell_int(cbm_sb_t *sb, long long v, bool first); +void cbm_tree_cell_real(cbm_sb_t *sb, double v, bool first); +void cbm_tree_cell_bool(cbm_sb_t *sb, bool v, bool first); +void cbm_tree_row_end(cbm_sb_t *sb); #endif /* CBM_MCP_COMPACT_OUT_H */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index b16304663..6062416aa 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -370,12 +370,13 @@ static const tool_def_t TOOLS[] = { "(2) name_pattern='.*regex.*' for exact pattern matching; (3) semantic_query=[...] for " "vector cosine search that bridges vocabulary (finds 'publish' when you search 'send'). " "The three modes are independent and can be combined in a single call. " - "RESPONSE: compact TOON tables by default — `results[N]{qn,label,file,lines,in,out}:` " - "header then one row per hit. in/out = TOTAL degree across ALL edge types (DEFINES, " + "RESPONSE: prefix-grouped tree rows by default — a shared (qn-prefix, file) group " + "header printed once, then `name label lines in out` per row (full qn = group prefix " + "+ dot + name). in/out = TOTAL degree across ALL edge types (DEFINES, " "USAGE, CALLS, ...), NOT caller/callee counts — use trace_path for callers. Add per-node " "property columns via " - "fields (e.g. [\"complexity\",\"signature\",\"docstring\"]); pass format=\"json\" for " - "legacy verbose objects (include_connected always uses JSON). " + "fields (e.g. [\"complexity\",\"signature\",\"docstring\"]); format=\"json\" returns " + "the SAME tree model as structured JSON. " "PAGINATION: results are capped at limit (default 50). The response always includes " "'total' (full match count before limit) and 'has_more' (true when total > " "offset+returned). Detect truncation with has_more, then page by re-calling with " @@ -403,11 +404,11 @@ static const tool_def_t TOOLS[] = { "detect the limit and paginate.\"},\"offset\":{\"type\":\"integer\",\"default\":0," "\"description\":\"Skip the first N matching nodes. Combine with 'limit' to page: " "increment offset by limit and re-call while has_more is true.\"}," - "\"format\":{\"type\":\"string\",\"enum\":[\"toon\",\"tree\",\"json\"],\"default\":\"toon\"," - "\"description\":\"Response encoding. toon (default): compact header+rows tables, " - "~60% fewer tokens. json: legacy verbose per-node objects.\"}," + "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"," + "\"description\":\"Response encoding. tree (default): prefix-grouped text rows. " + "json: the SAME tree model as structured JSON (groups + column-ordered row arrays).\"}," "\"fields\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":" - "\"Extra per-node property columns for toon output, e.g. complexity, cognitive, " + "\"Extra per-node property columns, e.g. complexity, cognitive, " "signature, docstring, return_type, is_test, lines(int). Core row columns " "(qn/label/file/lines/in/out) are always present — do not request them here. " "Missing values emit as empty cells.\"}," @@ -454,10 +455,11 @@ static const tool_def_t TOOLS[] = { "Trace paths through the code graph. Modes: calls (callers/callees), data_flow (value " "propagation with args at each hop), cross_service (through HTTP/async Route nodes). " "Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing. " - "RESPONSE: compact TOON tables — `callees[N]{qn,hop}:`/`callers[N]{qn,hop}:` headers then " - "one row per reached node (hop = BFS distance); risk/test/args columns appear when the " - "matching flags are set. `truncated: true` means a direction hit the node budget — re-call " - "with a higher 'limit'. Pass format=\"json\" for legacy verbose objects.", + "RESPONSE: prefix-grouped tree rows — callees/callers grouped under their shared " + "qn-prefix, `name hop` per row (full qn = group prefix + dot + name); exact " + "callees_total/callers_total on every page; risk/args flags use a flat table. " + "`truncated: true` + `next` = more rows — pass next back as cursor. " + "format=\"json\" returns the SAME tree model as structured JSON.", "{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"project\":{" "\"type\":\"string\"},\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\"," "\"both\"],\"default\":\"both\"},\"depth\":{\"type\":\"integer\",\"default\":3}," @@ -480,9 +482,9 @@ static const tool_def_t TOOLS[] = { "\"},\"include_tests\":{\"type\":\"boolean\",\"default\":false," "\"description\":\"Include test files in results. When false (default), test files are " "filtered out. When true, test nodes are included with a test column/marker.\"}," - "\"format\":{\"type\":\"string\",\"enum\":[\"toon\",\"tree\",\"json\"],\"default\":\"toon\"," - "\"description\":\"Response encoding. toon (default): compact header+rows tables. " - "json: legacy verbose per-hop objects.\"}}," + "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"," + "\"description\":\"Response encoding. tree (default): prefix-grouped text rows. " + "json: the SAME tree model as structured JSON (groups + column-ordered row arrays).\"}}," "\"required\":[\"function_name\",\"project\"]}"}, {"get_code_snippet", "Get code snippet", @@ -2372,14 +2374,14 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu } else { lines[0] = '\0'; } - cbm_toon_row_begin(&rows); - cbm_toon_cell_str(&rows, (const char *)sqlite3_column_text(stmt, BM25_COL_QN), true); - cbm_toon_cell_str(&rows, (const char *)sqlite3_column_text(stmt, BM25_COL_LABEL), + cbm_tree_row_begin(&rows); + cbm_tree_cell_str(&rows, (const char *)sqlite3_column_text(stmt, BM25_COL_QN), true); + cbm_tree_cell_str(&rows, (const char *)sqlite3_column_text(stmt, BM25_COL_LABEL), false); - cbm_toon_cell_str(&rows, (const char *)sqlite3_column_text(stmt, BM25_COL_FILE), false); - cbm_toon_cell_str(&rows, lines, false); - cbm_toon_cell_real(&rows, sqlite3_column_double(stmt, BM25_COL_RANK), false); - cbm_toon_row_end(&rows); + cbm_tree_cell_str(&rows, (const char *)sqlite3_column_text(stmt, BM25_COL_FILE), false); + cbm_tree_cell_str(&rows, lines, false); + cbm_tree_cell_real(&rows, sqlite3_column_double(stmt, BM25_COL_RANK), false); + cbm_tree_row_end(&rows); emitted++; } sqlite3_finalize(stmt); @@ -2387,14 +2389,14 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu cbm_sb_t sb; cbm_sb_init(&sb); - cbm_toon_scalar_int(&sb, "total", total); - cbm_toon_scalar_str(&sb, "search_mode", "bm25"); + cbm_tree_scalar_int(&sb, "total", total); + cbm_tree_scalar_str(&sb, "search_mode", "bm25"); static const char *const cols[] = {"qn", "label", "file", "lines", "rank"}; - cbm_toon_table_header(&sb, "results", emitted, cols, 5); + cbm_tree_table_header(&sb, "results", emitted, cols, 5); char *rows_text = cbm_sb_finish(&rows); cbm_sb_append(&sb, rows_text ? rows_text : ""); free(rows_text); - cbm_toon_scalar_bool(&sb, "has_more", total > offset + emitted); + cbm_tree_scalar_bool(&sb, "has_more", total > offset + emitted); return cbm_sb_finish(&sb); } @@ -2651,15 +2653,15 @@ static void sg_toon_extra_cells(cbm_sb_t *sb, const char *props_json, const char for (int f = 0; f < nfields; f++) { yyjson_val *v = (pr && yyjson_is_obj(pr)) ? yyjson_obj_get(pr, fields[f]) : NULL; if (v && yyjson_is_str(v)) { - cbm_toon_cell_str(sb, yyjson_get_str(v), false); + cbm_tree_cell_str(sb, yyjson_get_str(v), false); } else if (v && yyjson_is_bool(v)) { - cbm_toon_cell_bool(sb, yyjson_get_bool(v), false); + cbm_tree_cell_bool(sb, yyjson_get_bool(v), false); } else if (v && yyjson_is_int(v)) { - cbm_toon_cell_int(sb, yyjson_get_int(v), false); + cbm_tree_cell_int(sb, yyjson_get_int(v), false); } else if (v && yyjson_is_real(v)) { - cbm_toon_cell_real(sb, yyjson_get_real(v), false); + cbm_tree_cell_real(sb, yyjson_get_real(v), false); } else { - cbm_toon_cell_str(sb, "", false); + cbm_tree_cell_str(sb, "", false); } } if (pd) { @@ -2679,18 +2681,18 @@ static void sg_lines_str(char *out, size_t sz, int start, int end) { /* Emit the regex-path search results as a TOON table. */ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *out, int offset, const char *const *fields, int nfields, bool detail_ids) { - cbm_toon_scalar_int(sb, "total", out->total); + cbm_tree_scalar_int(sb, "total", out->total); if (detail_ids) { /* ids tier: bare qn enumeration — for "list everything matching X" * sweeps where per-row metadata is noise (LocAgent's fold tier). */ static const char *const id_cols[] = {"qn"}; - cbm_toon_table_header(sb, "results", out->count, id_cols, 1); + cbm_tree_table_header(sb, "results", out->count, id_cols, 1); for (int i = 0; i < out->count; i++) { - cbm_toon_row_begin(sb); - cbm_toon_cell_str(sb, out->results[i].node.qualified_name, true); - cbm_toon_row_end(sb); + cbm_tree_row_begin(sb); + cbm_tree_cell_str(sb, out->results[i].node.qualified_name, true); + cbm_tree_row_end(sb); } - cbm_toon_scalar_bool(sb, "has_more", out->total > offset + out->count); + cbm_tree_scalar_bool(sb, "has_more", out->total > offset + out->count); return; } const char *cols[6 + SG_MAX_EXTRA_FIELDS] = {"qn", "label", "file", "lines", "in", "out"}; @@ -2698,22 +2700,22 @@ static void emit_search_results_toon(cbm_sb_t *sb, const cbm_search_output_t *ou for (int f = 0; f < nfields; f++) { cols[ncols++] = fields[f]; } - cbm_toon_table_header(sb, "results", out->count, cols, ncols); + cbm_tree_table_header(sb, "results", out->count, cols, ncols); for (int i = 0; i < out->count; i++) { const cbm_search_result_t *sr = &out->results[i]; char lines[CBM_SZ_32]; sg_lines_str(lines, sizeof(lines), sr->node.start_line, sr->node.end_line); - cbm_toon_row_begin(sb); - cbm_toon_cell_str(sb, sr->node.qualified_name, true); - cbm_toon_cell_str(sb, sr->node.label, false); - cbm_toon_cell_str(sb, sr->node.file_path, false); - cbm_toon_cell_str(sb, lines, false); - cbm_toon_cell_int(sb, sr->in_degree, false); - cbm_toon_cell_int(sb, sr->out_degree, false); + cbm_tree_row_begin(sb); + cbm_tree_cell_str(sb, sr->node.qualified_name, true); + cbm_tree_cell_str(sb, sr->node.label, false); + cbm_tree_cell_str(sb, sr->node.file_path, false); + cbm_tree_cell_str(sb, lines, false); + cbm_tree_cell_int(sb, sr->in_degree, false); + cbm_tree_cell_int(sb, sr->out_degree, false); sg_toon_extra_cells(sb, sr->node.properties_json, fields, nfields); - cbm_toon_row_end(sb); + cbm_tree_row_end(sb); } - cbm_toon_scalar_bool(sb, "has_more", out->total > offset + out->count); + cbm_tree_scalar_bool(sb, "has_more", out->total > offset + out->count); } /* ── Tree format (Phase-2 A/B candidate) ──────────────────────────── @@ -2779,10 +2781,10 @@ static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int /* Extra property columns (fields param), space-delimited; missing * values emit as "-" so column positions stay stable. */ if (nfields > 0) { - yyjson_doc *pd = (sr->node.properties_json && sr->node.properties_json[0]) - ? yyjson_read(sr->node.properties_json, - strlen(sr->node.properties_json), 0) - : NULL; + yyjson_doc *pd = + (sr->node.properties_json && sr->node.properties_json[0]) + ? yyjson_read(sr->node.properties_json, strlen(sr->node.properties_json), 0) + : NULL; yyjson_val *pr = pd ? yyjson_doc_get_root(pd) : NULL; for (int f = 0; f < nfields; f++) { yyjson_val *v = (pr && yyjson_is_obj(pr)) ? yyjson_obj_get(pr, fields[f]) : NULL; @@ -2811,18 +2813,99 @@ static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int cbm_sb_append(sb, buf); } +/* json-stringified tree: the SAME grouped model as the text tree, serialized + * as JSON for agents that need structured parsing — groups with a shared + * (qn_prefix, file) and column-ordered row ARRAYS (never per-row key + * envelopes; that legacy shape was 84% key overhead). */ +static void emit_search_results_tree_json(yyjson_mut_doc *doc, yyjson_mut_val *root, + cbm_search_output_t *out, int offset, + const char *const *fields, int nfields) { + yyjson_mut_obj_add_int(doc, root, "total", out->total); + yyjson_mut_obj_add_int(doc, root, "count", out->count); + yyjson_mut_val *cols = yyjson_mut_arr(doc); + static const char *const col_names[] = {"name", "label", "lines", "in", "out"}; + for (size_t i = 0; i < sizeof(col_names) / sizeof(col_names[0]); i++) { + yyjson_mut_arr_add_str(doc, cols, col_names[i]); + } + for (int f = 0; f < nfields; f++) { + yyjson_mut_arr_add_strcpy(doc, cols, fields[f]); + } + yyjson_mut_obj_add_val(doc, root, "cols", cols); + if (out->count > 1) { + qsort(out->results, (size_t)out->count, sizeof(cbm_search_result_t), sg_cmp_by_qn); + } + yyjson_mut_val *groups = yyjson_mut_arr(doc); + yyjson_mut_val *cur = NULL; + yyjson_mut_val *cur_rows = NULL; + char cur_key[CBM_SZ_1K] = ""; + for (int i = 0; i < out->count; i++) { + const cbm_search_result_t *sr = &out->results[i]; + const char *qn = sr->node.qualified_name ? sr->node.qualified_name : ""; + const char *file = sr->node.file_path ? sr->node.file_path : ""; + size_t plen = sg_qn_prefix_len(qn); + char key[CBM_SZ_1K]; + snprintf(key, sizeof(key), "%.*s|%s", (int)plen, qn, file); + if (!cur || strcmp(key, cur_key) != 0) { + snprintf(cur_key, sizeof(cur_key), "%s", key); + cur = yyjson_mut_obj(doc); + char prefix[CBM_SZ_1K]; + snprintf(prefix, sizeof(prefix), "%.*s", (int)plen, qn); + yyjson_mut_obj_add_strcpy(doc, cur, "qn_prefix", prefix); + yyjson_mut_obj_add_strcpy(doc, cur, "file", file); + cur_rows = yyjson_mut_arr(doc); + yyjson_mut_obj_add_val(doc, cur, "rows", cur_rows); + yyjson_mut_arr_add_val(groups, cur); + } + char lines[CBM_SZ_32]; + sg_lines_str(lines, sizeof(lines), sr->node.start_line, sr->node.end_line); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, plen ? qn + plen + 1 : qn); + yyjson_mut_arr_add_strcpy(doc, row, sr->node.label ? sr->node.label : ""); + yyjson_mut_arr_add_strcpy(doc, row, lines); + yyjson_mut_arr_add_int(doc, row, sr->in_degree); + yyjson_mut_arr_add_int(doc, row, sr->out_degree); + if (nfields > 0) { + yyjson_doc *pd = + (sr->node.properties_json && sr->node.properties_json[0]) + ? yyjson_read(sr->node.properties_json, strlen(sr->node.properties_json), 0) + : NULL; + yyjson_val *pr = pd ? yyjson_doc_get_root(pd) : NULL; + for (int f = 0; f < nfields; f++) { + yyjson_val *v = (pr && yyjson_is_obj(pr)) ? yyjson_obj_get(pr, fields[f]) : NULL; + if (v && yyjson_is_str(v)) { + yyjson_mut_arr_add_strcpy(doc, row, yyjson_get_str(v)); + } else if (v && yyjson_is_int(v)) { + yyjson_mut_arr_add_int(doc, row, yyjson_get_int(v)); + } else if (v && yyjson_is_real(v)) { + yyjson_mut_arr_add_real(doc, row, yyjson_get_real(v)); + } else if (v && yyjson_is_bool(v)) { + yyjson_mut_arr_add_bool(doc, row, yyjson_get_bool(v)); + } else { + yyjson_mut_arr_add_null(doc, row); + } + } + if (pd) { + yyjson_doc_free(pd); + } + } + yyjson_mut_arr_add_val(cur_rows, row); + } + yyjson_mut_obj_add_val(doc, root, "groups", groups); + yyjson_mut_obj_add_bool(doc, root, "has_more", out->total > offset + out->count); +} + /* Emit semantic vector-search results as a TOON table. */ static void emit_semantic_results_toon(cbm_sb_t *sb, const cbm_vector_result_t *vresults, int vcount) { static const char *const cols[] = {"qn", "label", "file", "score"}; - cbm_toon_table_header(sb, "semantic", vcount, cols, 4); + cbm_tree_table_header(sb, "semantic", vcount, cols, 4); for (int v = 0; v < vcount; v++) { - cbm_toon_row_begin(sb); - cbm_toon_cell_str(sb, vresults[v].qualified_name, true); - cbm_toon_cell_str(sb, vresults[v].label, false); - cbm_toon_cell_str(sb, vresults[v].file_path, false); - cbm_toon_cell_real(sb, vresults[v].score, false); - cbm_toon_row_end(sb); + cbm_tree_row_begin(sb); + cbm_tree_cell_str(sb, vresults[v].qualified_name, true); + cbm_tree_cell_str(sb, vresults[v].label, false); + cbm_tree_cell_str(sb, vresults[v].file_path, false); + cbm_tree_cell_real(sb, vresults[v].score, false); + cbm_tree_row_end(sb); } } @@ -2837,18 +2920,16 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { return not_indexed; } - /* Response encoding: TOON tables by default (compact, header+rows). + /* Response encoding: grouped tree rows by default. * format:"json" restores the legacy verbose per-node objects; nested * neighbor lists (include_connected) need them, so they force JSON. * format:"tree" is the Phase-2 A/B candidate: rows grouped by * (qn-prefix, file) with the shared prefix printed once. */ char *format_arg = cbm_mcp_get_string_arg(args, "format"); bool legacy_json = format_arg && strcmp(format_arg, "json") == 0; - bool tree_format = format_arg && strcmp(format_arg, "tree") == 0; free(format_arg); if (cbm_mcp_get_bool_arg(args, "include_connected")) { legacy_json = true; - tree_format = false; } /* BM25 path: if `query` is set, run FTS5 full-text search with ranking @@ -2941,14 +3022,13 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { cbm_store_search(store, ¶ms, &tout); /* Grouped tree output is THE default; the flat table remains * only for detail:"ids" (single column — nothing to group). */ - (void)tree_format; if (detail_ids) { emit_search_results_toon(&sb, &tout, offset, fields, nfields, detail_ids); } else { emit_search_results_tree(&sb, &tout, offset, fields, nfields); } if (core_fields_requested) { - cbm_toon_scalar_str( + cbm_tree_scalar_str( &sb, "hint", "some requested fields (file/name/qn/label/lines) are already core " "row columns and were skipped — `fields` is for extra property " @@ -2956,15 +3036,15 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } if (tout.total == 0) { if (name_pattern && label) { - cbm_toon_scalar_str(&sb, "hint", + cbm_tree_scalar_str(&sb, "hint", "No results. Try removing the label filter or " "broadening the name_pattern regex."); } else if (name_pattern) { - cbm_toon_scalar_str( + cbm_tree_scalar_str( &sb, "hint", "No nodes match this pattern. Check spelling or try a broader regex."); } else if (label) { - cbm_toon_scalar_str(&sb, "hint", + cbm_tree_scalar_str(&sb, "hint", "No nodes with this label. Available labels: " "Function, Method, Class, Interface, Route, " "Variable, Module, Package, File, Folder."); @@ -2975,8 +3055,8 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { emit_semantic_results_toon(&sb, vresults, vcount); } else if (semantic_only) { static const char *const sem_cols[] = {"qn", "label", "file", "score"}; - cbm_toon_table_header(&sb, "semantic", 0, sem_cols, 4); - cbm_toon_scalar_str(&sb, "hint", + cbm_tree_table_header(&sb, "semantic", 0, sem_cols, 4); + cbm_tree_scalar_str(&sb, "hint", "No semantic matches. semantic_query needs a moderate/full " "index; try broader or fewer keywords."); } @@ -3025,8 +3105,23 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { yyjson_doc **props_docs = NULL; int props_doc_count = 0; - emit_search_results(doc, root, &out, store, relationship, include_connected, offset, - &props_docs, &props_doc_count); + if (include_connected) { + /* Nested neighbor lists keep the per-node object shape (the only + * remaining consumer of it; conversion tracked for the tree-json + * model's nested form). */ + emit_search_results(doc, root, &out, store, relationship, include_connected, offset, + &props_docs, &props_doc_count); + } else { + /* format:"json" = json-stringified tree: same grouped model as the + * default text output, structured for parsing. */ + const char *jfields[SG_MAX_EXTRA_FIELDS]; + yyjson_doc *jfields_owner = NULL; + int jnfields = sg_parse_fields(args, jfields, SG_MAX_EXTRA_FIELDS, &jfields_owner, NULL); + emit_search_results_tree_json(doc, root, &out, offset, jfields, jnfields); + if (jfields_owner) { + yyjson_doc_free(jfields_owner); + } + } /* Add diagnostic hint when zero results */ if (out.total == 0) { @@ -3157,21 +3252,21 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) { if (!qg_legacy_json) { cbm_sb_t sb; cbm_sb_init(&sb); - cbm_toon_table_header(&sb, "rows", result.row_count, (const char *const *)result.columns, + cbm_tree_table_header(&sb, "rows", result.row_count, (const char *const *)result.columns, result.col_count); for (int r = 0; r < result.row_count; r++) { - cbm_toon_row_begin(&sb); + cbm_tree_row_begin(&sb); for (int c = 0; c < result.col_count; c++) { - cbm_toon_cell_str(&sb, result.rows[r][c], c == 0); + cbm_tree_cell_str(&sb, result.rows[r][c], c == 0); } - cbm_toon_row_end(&sb); + cbm_tree_row_end(&sb); } - cbm_toon_scalar_int(&sb, "total", result.row_count); + cbm_tree_scalar_int(&sb, "total", result.row_count); if (result.warning) { - cbm_toon_scalar_str(&sb, "warning", result.warning); + cbm_tree_scalar_str(&sb, "warning", result.warning); } if (result.row_count == 0) { - cbm_toon_scalar_str(&sb, "hint", + cbm_tree_scalar_str(&sb, "hint", "Query returned no results. Use get_graph_schema() to see " "available labels and edge types."); } @@ -4062,7 +4157,7 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { char norm_path[CBM_SZ_512]; bool path_scoped = cbm_store_normalize_arch_path(scope_path, norm_path, sizeof(norm_path)); - /* Response encoding: TOON tables by default; format:"json" restores the + /* Response encoding: tree tables by default; format:"json" emits the * legacy per-item objects. */ char *arch_format = cbm_mcp_get_string_arg(args, "format"); bool arch_legacy_json = arch_format && strcmp(arch_format, "json") == 0; @@ -4072,172 +4167,172 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { cbm_sb_t sb; cbm_sb_init(&sb); if (project) { - cbm_toon_scalar_str(&sb, "project", project); + cbm_tree_scalar_str(&sb, "project", project); } if (default_summary) { - cbm_toon_scalar_str(&sb, "aspects_hint", + cbm_tree_scalar_str(&sb, "aspects_hint", "Summary view (default). More on request via aspects:[...] — " "structure, dependencies, routes, hotspots, boundaries, layers, " "clusters, file_tree — or [\"all\"] for everything."); } if (path_scoped) { - cbm_toon_scalar_str(&sb, "path", norm_path); - cbm_toon_scalar_int(&sb, "root_total_nodes", cbm_store_count_nodes(store, project)); - cbm_toon_scalar_int(&sb, "root_total_edges", cbm_store_count_edges(store, project)); - cbm_toon_scalar_int(&sb, "scoped_total_nodes", node_count); - cbm_toon_scalar_int(&sb, "scoped_total_edges", edge_count); + cbm_tree_scalar_str(&sb, "path", norm_path); + cbm_tree_scalar_int(&sb, "root_total_nodes", cbm_store_count_nodes(store, project)); + cbm_tree_scalar_int(&sb, "root_total_edges", cbm_store_count_edges(store, project)); + cbm_tree_scalar_int(&sb, "scoped_total_nodes", node_count); + cbm_tree_scalar_int(&sb, "scoped_total_edges", edge_count); } - cbm_toon_scalar_int(&sb, "total_nodes", node_count); - cbm_toon_scalar_int(&sb, "total_edges", edge_count); + cbm_tree_scalar_int(&sb, "total_nodes", node_count); + cbm_tree_scalar_int(&sb, "total_edges", edge_count); if (aspect_wanted(aspects_doc, aspects_arr, "structure") && schema.node_label_count > 0) { static const char *const lcols[] = {"label", "count"}; - cbm_toon_table_header(&sb, "node_labels", schema.node_label_count, lcols, 2); + cbm_tree_table_header(&sb, "node_labels", schema.node_label_count, lcols, 2); for (int i = 0; i < schema.node_label_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, schema.node_labels[i].label, true); - cbm_toon_cell_int(&sb, schema.node_labels[i].count, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, schema.node_labels[i].label, true); + cbm_tree_cell_int(&sb, schema.node_labels[i].count, false); + cbm_tree_row_end(&sb); } } if (aspect_wanted(aspects_doc, aspects_arr, "dependencies") && schema.edge_type_count > 0) { static const char *const tcols[] = {"type", "count"}; - cbm_toon_table_header(&sb, "edge_types", schema.edge_type_count, tcols, 2); + cbm_tree_table_header(&sb, "edge_types", schema.edge_type_count, tcols, 2); for (int i = 0; i < schema.edge_type_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, schema.edge_types[i].type, true); - cbm_toon_cell_int(&sb, schema.edge_types[i].count, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, schema.edge_types[i].type, true); + cbm_tree_cell_int(&sb, schema.edge_types[i].count, false); + cbm_tree_row_end(&sb); } } if (aspect_wanted(aspects_doc, aspects_arr, "routes") && schema.rel_pattern_count > 0) { static const char *const pcols[] = {"pattern"}; - cbm_toon_table_header(&sb, "relationship_patterns", schema.rel_pattern_count, pcols, 1); + cbm_tree_table_header(&sb, "relationship_patterns", schema.rel_pattern_count, pcols, 1); for (int i = 0; i < schema.rel_pattern_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, schema.rel_patterns[i], true); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, schema.rel_patterns[i], true); + cbm_tree_row_end(&sb); } } if (arch.language_count > 0) { static const char *const gcols[] = {"language", "files"}; - cbm_toon_table_header(&sb, "languages", arch.language_count, gcols, 2); + cbm_tree_table_header(&sb, "languages", arch.language_count, gcols, 2); for (int i = 0; i < arch.language_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.languages[i].language, true); - cbm_toon_cell_int(&sb, arch.languages[i].file_count, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.languages[i].language, true); + cbm_tree_cell_int(&sb, arch.languages[i].file_count, false); + cbm_tree_row_end(&sb); } } if (arch.package_count > 0) { static const char *const kcols[] = {"name", "nodes", "fan_in", "fan_out"}; - cbm_toon_table_header(&sb, "packages", arch.package_count, kcols, 4); + cbm_tree_table_header(&sb, "packages", arch.package_count, kcols, 4); for (int i = 0; i < arch.package_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.packages[i].name, true); - cbm_toon_cell_int(&sb, arch.packages[i].node_count, false); - cbm_toon_cell_int(&sb, arch.packages[i].fan_in, false); - cbm_toon_cell_int(&sb, arch.packages[i].fan_out, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.packages[i].name, true); + cbm_tree_cell_int(&sb, arch.packages[i].node_count, false); + cbm_tree_cell_int(&sb, arch.packages[i].fan_in, false); + cbm_tree_cell_int(&sb, arch.packages[i].fan_out, false); + cbm_tree_row_end(&sb); } } if (arch.entry_point_count > 0) { /* qn only — `name` is its last segment. */ static const char *const ecols[] = {"qn", "file"}; - cbm_toon_table_header(&sb, "entry_points", arch.entry_point_count, ecols, 2); + cbm_tree_table_header(&sb, "entry_points", arch.entry_point_count, ecols, 2); for (int i = 0; i < arch.entry_point_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.entry_points[i].qualified_name, true); - cbm_toon_cell_str(&sb, arch.entry_points[i].file, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.entry_points[i].qualified_name, true); + cbm_tree_cell_str(&sb, arch.entry_points[i].file, false); + cbm_tree_row_end(&sb); } } if (arch.route_count > 0) { static const char *const rcols[] = {"method", "path", "handler"}; - cbm_toon_table_header(&sb, "routes", arch.route_count, rcols, 3); + cbm_tree_table_header(&sb, "routes", arch.route_count, rcols, 3); for (int i = 0; i < arch.route_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.routes[i].method, true); - cbm_toon_cell_str(&sb, arch.routes[i].path, false); - cbm_toon_cell_str(&sb, arch.routes[i].handler, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.routes[i].method, true); + cbm_tree_cell_str(&sb, arch.routes[i].path, false); + cbm_tree_cell_str(&sb, arch.routes[i].handler, false); + cbm_tree_row_end(&sb); } } if (arch.hotspot_count > 0) { static const char *const hcols[] = {"qn", "fan_in"}; - cbm_toon_table_header(&sb, "hotspots", arch.hotspot_count, hcols, 2); + cbm_tree_table_header(&sb, "hotspots", arch.hotspot_count, hcols, 2); for (int i = 0; i < arch.hotspot_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.hotspots[i].qualified_name, true); - cbm_toon_cell_int(&sb, arch.hotspots[i].fan_in, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.hotspots[i].qualified_name, true); + cbm_tree_cell_int(&sb, arch.hotspots[i].fan_in, false); + cbm_tree_row_end(&sb); } } if (arch.boundary_count > 0) { static const char *const bcols[] = {"from", "to", "calls"}; - cbm_toon_table_header(&sb, "boundaries", arch.boundary_count, bcols, 3); + cbm_tree_table_header(&sb, "boundaries", arch.boundary_count, bcols, 3); for (int i = 0; i < arch.boundary_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.boundaries[i].from, true); - cbm_toon_cell_str(&sb, arch.boundaries[i].to, false); - cbm_toon_cell_int(&sb, arch.boundaries[i].call_count, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.boundaries[i].from, true); + cbm_tree_cell_str(&sb, arch.boundaries[i].to, false); + cbm_tree_cell_int(&sb, arch.boundaries[i].call_count, false); + cbm_tree_row_end(&sb); } } if (arch.service_count > 0) { static const char *const scols[] = {"from", "to", "type", "count"}; - cbm_toon_table_header(&sb, "services", arch.service_count, scols, 4); + cbm_tree_table_header(&sb, "services", arch.service_count, scols, 4); for (int i = 0; i < arch.service_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.services[i].from, true); - cbm_toon_cell_str(&sb, arch.services[i].to, false); - cbm_toon_cell_str(&sb, arch.services[i].type, false); - cbm_toon_cell_int(&sb, arch.services[i].count, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.services[i].from, true); + cbm_tree_cell_str(&sb, arch.services[i].to, false); + cbm_tree_cell_str(&sb, arch.services[i].type, false); + cbm_tree_cell_int(&sb, arch.services[i].count, false); + cbm_tree_row_end(&sb); } } if (arch.layer_count > 0) { static const char *const ycols[] = {"name", "layer", "reason"}; - cbm_toon_table_header(&sb, "layers", arch.layer_count, ycols, 3); + cbm_tree_table_header(&sb, "layers", arch.layer_count, ycols, 3); for (int i = 0; i < arch.layer_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.layers[i].name, true); - cbm_toon_cell_str(&sb, arch.layers[i].layer, false); - cbm_toon_cell_str(&sb, arch.layers[i].reason, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.layers[i].name, true); + cbm_tree_cell_str(&sb, arch.layers[i].layer, false); + cbm_tree_cell_str(&sb, arch.layers[i].reason, false); + cbm_tree_row_end(&sb); } } if (arch.cluster_count > 0) { /* Nested lists become ';'-joined cells. */ static const char *const ccols[] = {"id", "label", "members", "cohesion", "top_nodes", "packages", "edge_types"}; - cbm_toon_table_header(&sb, "clusters", arch.cluster_count, ccols, 7); + cbm_tree_table_header(&sb, "clusters", arch.cluster_count, ccols, 7); for (int i = 0; i < arch.cluster_count; i++) { const cbm_cluster_info_t *c = &arch.clusters[i]; char joined[CBM_SZ_1K]; - cbm_toon_row_begin(&sb); - cbm_toon_cell_int(&sb, c->id, true); - cbm_toon_cell_str(&sb, c->label, false); - cbm_toon_cell_int(&sb, c->members, false); - cbm_toon_cell_real(&sb, c->cohesion, false); + cbm_tree_row_begin(&sb); + cbm_tree_cell_int(&sb, c->id, true); + cbm_tree_cell_str(&sb, c->label, false); + cbm_tree_cell_int(&sb, c->members, false); + cbm_tree_cell_real(&sb, c->cohesion, false); arch_join_list(joined, sizeof(joined), c->top_nodes, c->top_node_count); - cbm_toon_cell_str(&sb, joined, false); + cbm_tree_cell_str(&sb, joined, false); arch_join_list(joined, sizeof(joined), c->packages, c->package_count); - cbm_toon_cell_str(&sb, joined, false); + cbm_tree_cell_str(&sb, joined, false); arch_join_list(joined, sizeof(joined), c->edge_types, c->edge_type_count); - cbm_toon_cell_str(&sb, joined, false); - cbm_toon_row_end(&sb); + cbm_tree_cell_str(&sb, joined, false); + cbm_tree_row_end(&sb); } } if (arch.file_tree_count > 0) { static const char *const fcols[] = {"path", "type", "children"}; - cbm_toon_table_header(&sb, "file_tree", arch.file_tree_count, fcols, 3); + cbm_tree_table_header(&sb, "file_tree", arch.file_tree_count, fcols, 3); for (int i = 0; i < arch.file_tree_count; i++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, arch.file_tree[i].path, true); - cbm_toon_cell_str(&sb, arch.file_tree[i].type, false); - cbm_toon_cell_int(&sb, arch.file_tree[i].children, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, arch.file_tree[i].path, true); + cbm_tree_cell_str(&sb, arch.file_tree[i].type, false); + cbm_tree_cell_int(&sb, arch.file_tree[i].children, false); + cbm_tree_row_end(&sb); } } /* Cross-repo edge summary (mirrors append_cross_repo_summary). */ @@ -4255,7 +4350,7 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { } } if (cross_total > 0) { - cbm_toon_scalar_int(&sb, "cross_repo_links_total", cross_total); + cbm_tree_scalar_int(&sb, "cross_repo_links_total", cross_total); } } @@ -4622,46 +4717,6 @@ static const char *bfs_edge_args_for_hop(cbm_traverse_result_t *tr, int64_t hop_ return NULL; } -static yyjson_mut_val *bfs_to_json_array(yyjson_mut_doc *doc, cbm_traverse_result_t *tr, - bool risk_labels, bool include_tests, bool data_flow) { - yyjson_mut_val *arr = yyjson_mut_arr(doc); - for (int i = 0; i < tr->visited_count; i++) { - const char *fp = tr->visited[i].node.file_path; - bool test = is_test_file(fp); - if (!include_tests && test) { - continue; - } - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", - tr->visited[i].node.name ? tr->visited[i].node.name : ""); - yyjson_mut_obj_add_str( - doc, item, "qualified_name", - tr->visited[i].node.qualified_name ? tr->visited[i].node.qualified_name : ""); - yyjson_mut_obj_add_int(doc, item, "hop", tr->visited[i].hop); - if (risk_labels) { - yyjson_mut_obj_add_str(doc, item, "risk", - cbm_risk_label(cbm_hop_to_risk(tr->visited[i].hop))); - } - if (test) { - yyjson_mut_obj_add_bool(doc, item, "is_test", true); - } - /* data_flow mode promises argument expressions at each call site; surface - * the CALLS edge's serialized args array as a raw JSON value (#514). */ - if (data_flow) { - size_t alen = 0; - const char *args = bfs_edge_args_for_hop(tr, tr->visited[i].node.id, &alen); - if (args && alen > 0) { - yyjson_mut_val *av = yyjson_mut_rawn(doc, args, alen); - if (av) { - yyjson_mut_obj_add_val(doc, item, "args", av); - } - } - } - yyjson_mut_arr_add_val(arr, item); - } - return arr; -} - /* TOON table for one trace direction: callees[N]{qn,hop,...} with optional * risk / test / args columns. `name` is omitted (it is the qn's last * segment); the per-item JSON key envelope was 84% of the legacy payload. */ @@ -4685,21 +4740,21 @@ static void bfs_to_toon_table(cbm_sb_t *sb, const char *key, cbm_traverse_result if (data_flow) { cols[ncols++] = "args"; } - cbm_toon_table_header(sb, key, visible, cols, ncols); + cbm_tree_table_header(sb, key, visible, cols, ncols); for (int i = 0; i < tr->visited_count; i++) { const char *fp = tr->visited[i].node.file_path; bool test = is_test_file(fp); if (!include_tests && test) { continue; } - cbm_toon_row_begin(sb); - cbm_toon_cell_str(sb, tr->visited[i].node.qualified_name, true); - cbm_toon_cell_int(sb, tr->visited[i].hop, false); + cbm_tree_row_begin(sb); + cbm_tree_cell_str(sb, tr->visited[i].node.qualified_name, true); + cbm_tree_cell_int(sb, tr->visited[i].hop, false); if (risk_labels) { - cbm_toon_cell_str(sb, cbm_risk_label(cbm_hop_to_risk(tr->visited[i].hop)), false); + cbm_tree_cell_str(sb, cbm_risk_label(cbm_hop_to_risk(tr->visited[i].hop)), false); } if (include_tests) { - cbm_toon_cell_bool(sb, test, false); + cbm_tree_cell_bool(sb, test, false); } if (data_flow) { size_t alen = 0; @@ -4708,12 +4763,12 @@ static void bfs_to_toon_table(cbm_sb_t *sb, const char *key, cbm_traverse_result char abuf[CBM_SZ_1K]; memcpy(abuf, ea, alen); abuf[alen] = '\0'; - cbm_toon_cell_str(sb, abuf, false); + cbm_tree_cell_str(sb, abuf, false); } else { - cbm_toon_cell_str(sb, "", false); + cbm_tree_cell_str(sb, "", false); } } - cbm_toon_row_end(sb); + cbm_tree_row_end(sb); } } @@ -4992,6 +5047,71 @@ static int trace_watermark_index(const cbm_traverse_result_t *tr, int hop, int64 return tr->visited_count; } +/* json-stringified tree for one trace leg: same grouped model as the text + * output — {cols, groups:[{qn_prefix, rows:[[name,hop,...]]}]}. Optional + * risk/args columns mirror the flags. */ +static yyjson_mut_val *bfs_to_tree_json(yyjson_mut_doc *doc, cbm_traverse_result_t *tr, + bool risk_labels, bool include_tests, bool data_flow) { + yyjson_mut_val *leg = yyjson_mut_obj(doc); + yyjson_mut_val *cols = yyjson_mut_arr(doc); + yyjson_mut_arr_add_str(doc, cols, "name"); + yyjson_mut_arr_add_str(doc, cols, "hop"); + if (risk_labels) { + yyjson_mut_arr_add_str(doc, cols, "risk"); + } + if (data_flow) { + yyjson_mut_arr_add_str(doc, cols, "args"); + } + yyjson_mut_obj_add_val(doc, leg, "cols", cols); + yyjson_mut_val *groups = yyjson_mut_arr(doc); + yyjson_mut_val *cur_rows = NULL; + char cur_group[CBM_SZ_1K] = ""; + bool have_group = false; + for (int i = 0; i < tr->visited_count; i++) { + if (!include_tests && is_test_file(tr->visited[i].node.file_path)) { + continue; + } + const char *qn = + tr->visited[i].node.qualified_name ? tr->visited[i].node.qualified_name : ""; + size_t plen = sg_qn_prefix_len(qn); + if (plen >= sizeof(cur_group)) { + plen = 0; + } + if (!have_group || strncmp(cur_group, qn, plen) != 0 || cur_group[plen] != '\0') { + snprintf(cur_group, sizeof(cur_group), "%.*s", (int)plen, qn); + have_group = true; + yyjson_mut_val *g = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, g, "qn_prefix", cur_group); + cur_rows = yyjson_mut_arr(doc); + yyjson_mut_obj_add_val(doc, g, "rows", cur_rows); + yyjson_mut_arr_add_val(groups, g); + } + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, plen ? qn + plen + 1 : qn); + yyjson_mut_arr_add_int(doc, row, tr->visited[i].hop); + if (risk_labels) { + yyjson_mut_arr_add_str(doc, row, cbm_risk_label(cbm_hop_to_risk(tr->visited[i].hop))); + } + if (data_flow) { + size_t alen = 0; + const char *ea = bfs_edge_args_for_hop(tr, tr->visited[i].node.id, &alen); + if (ea && alen > 0) { + yyjson_mut_val *av = yyjson_mut_rawn(doc, ea, alen); + if (av) { + yyjson_mut_arr_add_val(row, av); + } else { + yyjson_mut_arr_add_str(doc, row, ""); + } + } else { + yyjson_mut_arr_add_str(doc, row, ""); + } + } + yyjson_mut_arr_add_val(cur_rows, row); + } + yyjson_mut_obj_add_val(doc, leg, "groups", groups); + return leg; +} + /* Tree-format trace leg: rows grouped by qn-prefix (printed once), each row * `name hop` — same data as the TOON table, prefix-factored. Test-file rows * honor include_tests exactly like bfs_to_toon_table. Rows arrive in @@ -5219,12 +5339,11 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { return result; } - /* Response encoding: TOON tables by default; format:"json" restores the + /* Response encoding: tree tables by default; format:"json" emits the * legacy verbose per-hop objects; format:"tree" = Phase-2 A/B candidate * (qn-prefix-grouped rows). */ char *trace_format = cbm_mcp_get_string_arg(args, "format"); bool trace_legacy_json = trace_format && strcmp(trace_format, "json") == 0; - bool trace_tree = trace_format && strcmp(trace_format, "tree") == 0; free(trace_format); /* Edge types: explicit > mode-based > default */ @@ -5323,17 +5442,16 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { if (!trace_legacy_json) { cbm_sb_t sb; cbm_sb_init(&sb); - cbm_toon_scalar_str(&sb, "function", func_name); - cbm_toon_scalar_str(&sb, "direction", direction); + cbm_tree_scalar_str(&sb, "function", func_name); + cbm_tree_scalar_str(&sb, "direction", direction); if (mode) { - cbm_toon_scalar_str(&sb, "mode", mode); + cbm_tree_scalar_str(&sb, "mode", mode); } /* Grouped tree is THE default; risk_labels/data_flow keep the flat * table (extra columns) in the same tree syntax. */ - (void)trace_tree; bool flat_trace = risk_labels || data_flow; if (do_outbound) { - cbm_toon_scalar_int(&sb, "callees_total", tr_out.visited_count); + cbm_tree_scalar_int(&sb, "callees_total", tr_out.visited_count); if (flat_trace) { bfs_to_toon_table(&sb, "callees", &view_out, risk_labels, include_tests, data_flow); } else { @@ -5341,7 +5459,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } } if (do_inbound) { - cbm_toon_scalar_int(&sb, "callers_total", tr_in.visited_count); + cbm_tree_scalar_int(&sb, "callers_total", tr_in.visited_count); if (flat_trace) { bfs_to_toon_table(&sb, "callers", &view_in, risk_labels, include_tests, data_flow); } else { @@ -5349,9 +5467,9 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } } if (next_tok[0]) { - cbm_toon_scalar_bool(&sb, "truncated", true); - cbm_toon_scalar_str(&sb, "next", next_tok); - cbm_toon_scalar_str(&sb, "hint", + cbm_tree_scalar_bool(&sb, "truncated", true); + cbm_tree_scalar_str(&sb, "next", next_tok); + cbm_tree_scalar_str(&sb, "hint", "more rows exist — re-call with cursor set to 'next' and ALL " "other arguments identical (no duplicates), or narrow with " "depth/edge_types"); @@ -5371,13 +5489,13 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, root, "callees_total", tr_out.visited_count); yyjson_mut_obj_add_val( doc, root, "callees", - bfs_to_json_array(doc, &view_out, risk_labels, include_tests, data_flow)); + bfs_to_tree_json(doc, &view_out, risk_labels, include_tests, data_flow)); } if (do_inbound) { yyjson_mut_obj_add_int(doc, root, "callers_total", tr_in.visited_count); yyjson_mut_obj_add_val( doc, root, "callers", - bfs_to_json_array(doc, &view_in, risk_labels, include_tests, data_flow)); + bfs_to_tree_json(doc, &view_in, risk_labels, include_tests, data_flow)); } if (next_tok[0]) { yyjson_mut_obj_add_bool(doc, root, "truncated", true); @@ -7172,7 +7290,7 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep int output_count = sr_count < limit ? sr_count : limit; static const char *const cols[] = {"qn", "label", "file", "lines", "matches", "in", "out"}; - cbm_toon_table_header(&sb, "results", output_count, cols, 7); + cbm_tree_table_header(&sb, "results", output_count, cols, 7); for (int ri = 0; ri < output_count; ri++) { search_result_t *r = &sr[ri]; char lines[CBM_SZ_32]; @@ -7194,27 +7312,27 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep } mpos += (size_t)n; } - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, r->qualified_name, true); - cbm_toon_cell_str(&sb, r->label, false); - cbm_toon_cell_str(&sb, r->file, false); - cbm_toon_cell_str(&sb, lines, false); - cbm_toon_cell_str(&sb, matches, false); - cbm_toon_cell_int(&sb, r->in_degree, false); - cbm_toon_cell_int(&sb, r->out_degree, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, r->qualified_name, true); + cbm_tree_cell_str(&sb, r->label, false); + cbm_tree_cell_str(&sb, r->file, false); + cbm_tree_cell_str(&sb, lines, false); + cbm_tree_cell_str(&sb, matches, false); + cbm_tree_cell_int(&sb, r->in_degree, false); + cbm_tree_cell_int(&sb, r->out_degree, false); + cbm_tree_row_end(&sb); } int raw_output = raw_count < MAX_RAW ? raw_count : MAX_RAW; if (raw_output > 0) { static const char *const rcols[] = {"file", "line", "content"}; - cbm_toon_table_header(&sb, "raw", raw_output, rcols, 3); + cbm_tree_table_header(&sb, "raw", raw_output, rcols, 3); for (int ri = 0; ri < raw_output; ri++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, raw[ri].file, true); - cbm_toon_cell_int(&sb, raw[ri].line, false); - cbm_toon_cell_str(&sb, raw[ri].content, false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, raw[ri].file, true); + cbm_tree_cell_int(&sb, raw[ri].line, false); + cbm_tree_cell_str(&sb, raw[ri].content, false); + cbm_tree_row_end(&sb); } } @@ -7223,27 +7341,27 @@ static char *assemble_search_output_toon(search_result_t *sr, int sr_count, grep int dir_n = aggregate_search_dirs(sr, sr_count, dir_names, dir_counts, CBM_SZ_64); if (dir_n > 0) { static const char *const dcols[] = {"dir", "hits"}; - cbm_toon_table_header(&sb, "dirs", dir_n, dcols, 2); + cbm_tree_table_header(&sb, "dirs", dir_n, dcols, 2); for (int d = 0; d < dir_n; d++) { - cbm_toon_row_begin(&sb); - cbm_toon_cell_str(&sb, dir_names[d], true); - cbm_toon_cell_int(&sb, dir_counts[d], false); - cbm_toon_row_end(&sb); + cbm_tree_row_begin(&sb); + cbm_tree_cell_str(&sb, dir_names[d], true); + cbm_tree_cell_int(&sb, dir_counts[d], false); + cbm_tree_row_end(&sb); } } - cbm_toon_scalar_int(&sb, "total_grep_matches", gm_count); - cbm_toon_scalar_int(&sb, "total_results", sr_count); - cbm_toon_scalar_int(&sb, "raw_match_count", raw_count); - cbm_toon_scalar_int(&sb, "elapsed_ms", (long long)elapsed_ms); + cbm_tree_scalar_int(&sb, "total_grep_matches", gm_count); + cbm_tree_scalar_int(&sb, "total_results", sr_count); + cbm_tree_scalar_int(&sb, "raw_match_count", raw_count); + cbm_tree_scalar_int(&sb, "elapsed_ms", (long long)elapsed_ms); if (warn_literal_pipe) { - cbm_toon_scalar_str(&sb, "warning", + cbm_tree_scalar_str(&sb, "warning", "pattern contains '|' but regex=false, so it is matched literally " "(not as alternation). Pass regex=true for 'foo|bar' to mean " "'foo OR bar'."); } if (elapsed_ms >= SEARCH_SLOW_MS) { - cbm_toon_scalar_str(&sb, "warning_slow", + cbm_tree_scalar_str(&sb, "warning_slow", "search was slow; narrow file_pattern/path_filter or use a more " "specific pattern"); } @@ -7910,7 +8028,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { /* ── Phase 4: Context assembly (extracted helper) ─────────── */ - /* compact mode (default) emits TOON tables; format:"json" restores the + /* compact mode (default) emits tree tables; format:"json" keeps the * legacy per-hit objects. full/files keep their JSON shapes (full is * source-block-heavy; files is already minimal). */ char *sc_format = cbm_mcp_get_string_arg(args, "format"); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 0fdfa5e20..bfccf786b 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1132,18 +1132,24 @@ TEST(tool_search_graph_includes_node_properties) { free(inner); free(resp); - /* format:"json" keeps the legacy verbose objects intact. */ + /* format:"json" = json-stringified tree: same grouped model, column- + * ordered row arrays — never per-row key envelopes or property blobs. + * fields adds columns there too. */ resp = cbm_mcp_server_handle( srv, "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\"," "\"params\":{\"name\":\"search_graph\"," "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\"," - "\"name_pattern\":\"HandleRequest\",\"format\":\"json\",\"limit\":5}}}"); + "\"name_pattern\":\"HandleRequest\",\"format\":\"json\"," + "\"fields\":[\"signature\"],\"limit\":5}}}"); ASSERT_NOT_NULL(resp); inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); - ASSERT_NOT_NULL(strstr(inner, "\"signature\"")); - ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); - ASSERT_NOT_NULL(strstr(inner, "is_exported")); + ASSERT_NOT_NULL(strstr(inner, "\"qn_prefix\"")); /* grouped tree model */ + ASSERT_NOT_NULL(strstr(inner, "\"cols\"")); + ASSERT_NOT_NULL(strstr(inner, "\"rows\"")); + ASSERT_NOT_NULL(strstr(inner, "\"signature\"")); /* requested column */ + ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); /* its value */ + ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */ free(inner); free(resp); From 130a82bd106b9dd2edd7939b2555623f300c741b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 00:13:50 +0200 Subject: [PATCH 3/6] feat(mcp): real impact analysis + cycle detection (multi-source BFS, SCC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the graph-analysis overhaul. Two new capabilities on a shared multi-source traversal substrate, plus a context-bomb guard. detect_changes — rebuilt into real blast-radius analysis: The old handler parsed and echoed depth but never traversed: impacted was just the symbols DEFINED in changed files. It now resolves the git diff to those symbols, then runs ONE multi-source BFS to the transitive impact set. - default direction inbound = the blast radius (transitive callers that may need review); outbound/both available. - seeds are excluded from the result, and a changed file reached from another changed file is not counted as extra impact (that is not downstream fallout). - merge_base SHA pins the exact diff base for reproducibility. - impacted_modules: a 2-segment (src/mcp, tests/...) rollup — a quotient view of where the radius lands, always complete (overflow bucketed, never silently dropped). Per-symbol rows cap at limit (default 200, nearest hops first) with impacted_total exact and impacted_omitted surfaced. On a 7-file core diff: 1469 impacted symbols, 64KB unclipped -> 10.7KB with the rollup + cap. format:json returns the same model as structured JSON. cbm_store_bfs_multi (store): one recursive CTE anchored on ALL seeds via a temp table — never seed_count separate walks over overlapping hubs. Seed-exclusion NOT IN, MIN(hop) across the set, canonical (hop,id) order, a memory-safety ceiling that reports *truncated instead of silently capping. Reproduce-first: seed-exclusion + min-hop, and truncation. get_architecture aspects:[cycles] — SCC condensation: Iterative Tarjan (heap stacks, no recursion overflow) over the CALLS graph reports circular dependencies (strongly-connected components of size > 1). cbm_store_fetch_call_edges feeds it with a 400k-edge scan budget that reports partial coverage rather than truncating silently. Opt-in ONLY — never via a default or 'all' call, since it scans the whole call graph. On this repo it surfaces 26 real cycles (e.g. the C-LSP type-evaluation recursion) in ~5KB. Reproduce-first: a 3-node cycle is found, an acyclic chain is not, and a default call does not run the scan. get_code_snippet: cap the returned line span at 500 (a structural Module/File node spans its whole file — an unclipped read returned ~400KB in one call). source_clipped flags it; start_line/end_line still allow a targeted re-read. Full suite 6,372 passed / 0 failed; lint clean. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 809 ++++++++++++++++++++++++++++++++++++--- src/store/store.c | 196 ++++++++++ src/store/store.h | 16 + tests/test_incremental.c | 35 +- tests/test_integration.c | 121 ++++++ tests/test_mcp.c | 131 ++++++- 6 files changed, 1247 insertions(+), 61 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 6062416aa..a945ee051 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -25,8 +25,10 @@ enum { MCP_DEFAULT_DEPTH = 3, MCP_DEFAULT_BFS_DEPTH = 2, MCP_DEFAULT_LIMIT = 10, - MCP_BFS_LIMIT = 100, /* default per-direction trace budget (limit param raises) */ - MCP_BFS_LIMIT_MAX = 5000, /* hard ceiling for the limit param (context-bomb guard) */ + MCP_BFS_LIMIT = 100, /* default per-direction trace budget (limit param raises) */ + MCP_BFS_LIMIT_MAX = 5000, /* hard ceiling for the limit param (context-bomb guard) */ + MCP_DEFAULT_IMPACT_LIMIT = 200, /* detect_changes per-symbol rows; rollup stays complete */ + MCP_SNIPPET_MAX_LINES = 500, /* get_code_snippet line cap (whole-file Module guard) */ MCP_N_DEFAULTS_2 = 2, MCP_URI_PREFIX = 7, /* strlen("file://") */ MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */ @@ -519,9 +521,12 @@ static const tool_def_t TOOLS[] = { "apps/hoa)\"}," "\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"enum\":[\"all\"," "\"overview\",\"structure\",\"dependencies\",\"routes\",\"languages\",\"packages\"," - "\"entry_points\",\"hotspots\",\"boundaries\",\"layers\",\"file_tree\",\"clusters\"]}," + "\"entry_points\",\"hotspots\",\"boundaries\",\"layers\",\"file_tree\",\"clusters\"," + "\"cycles\"]}," "\"description\":\"Aspects to include. 'all' = everything; 'overview' = compact summary " - "(all except file_tree); omit = all.\"}},\"required\":[\"project\"]}"}, + "(all except file_tree); omit = all. 'cycles' is opt-in ONLY (never via all/overview): it " + "scans the whole call graph for circular CALLS dependencies (SCCs of size > 1).\"}}," + "\"required\":[\"project\"]}"}, {"search_code", "Search code", "Graph-augmented code search. Finds text patterns via grep, then enriches results with " @@ -591,11 +596,28 @@ static const tool_def_t TOOLS[] = { "\"required\":[\"project\"],\"anyOf\":[{\"required\":[\"paths\"]},{\"required\":[\"scopes\"]}]" "}"}, - {"detect_changes", "Detect changes", "Detect code changes and their impact", + {"detect_changes", "Detect changes", + "Map a git diff to its BLAST RADIUS. Resolves changed files to the symbols they define, then " + "runs ONE multi-source graph traversal to the transitive impact set. RESPONSE: base + " + "merge_base SHA, changed_files list, then impacted = prefix-grouped tree rows (name label " + "hop; " + "full qn = group prefix + dot + name) + an impacted_modules rollup; impacted_total + " + "truncated are exact. Seeds (the changed symbols) are excluded from impacted; a changed file " + "reached from another changed file is not counted as extra impact. format=\"json\" returns " + "the " + "same model as structured JSON.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":" - "\"string\"},\"depth\":{\"type\":\"integer\",\"default\":2},\"base_branch\":{\"type\":" + "\"string\",\"enum\":[\"files\",\"impact\"],\"description\":\"files: changed files only " + "(no traversal). impact (default): files + the transitive impact set.\"}," + "\"direction\":{\"type\":\"string\",\"enum\":[\"inbound\",\"outbound\",\"both\"],\"default\":" + "\"inbound\",\"description\":\"inbound (default) = the blast radius: transitive CALLERS of " + "the " + "changed symbols. outbound = what the changed code depends on. both = union.\"}," + "\"depth\":{\"type\":\"integer\",\"default\":2,\"description\":\"Max traversal hops from the " + "changed symbols.\"},\"base_branch\":{\"type\":" "\"string\",\"default\":\"main\"},\"since\":{\"type\":\"string\",\"description\":" - "\"Git ref or tag to compare from (e.g. HEAD~5, v0.5.0). Diffs ...HEAD.\"}}," + "\"Git ref or tag to compare from (e.g. HEAD~5, v0.5.0). Diffs ...HEAD.\"}," + "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," "\"required\":" "[\"project\"]}"}, @@ -3971,9 +3993,192 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { * of truth for the server-side validation (authoritative); the JSON-Schema * enum in the TOOLS entry above is the advisory client-side mirror — update * both together when the aspect set changes. */ -static const char *VALID_ASPECTS[] = { - "all", "overview", "structure", "dependencies", "routes", "languages", "packages", - "entry_points", "hotspots", "boundaries", "layers", "file_tree", "clusters", NULL}; +static const char *VALID_ASPECTS[] = {"all", "overview", "structure", "dependencies", + "routes", "languages", "packages", "entry_points", + "hotspots", "boundaries", "layers", "file_tree", + "clusters", "cycles", NULL}; + +/* ── SCC / cycle condensation (get_architecture "cycles") ───────── + * Iterative Tarjan over the CALLS call graph. Recursion would overflow on a + * large graph, so the DFS state lives on explicit heap stacks. Reports the + * strongly-connected components of size > 1 — the circular call dependencies, + * the non-trivial content of the condensation quotient. */ +typedef struct { + int64_t *ids; /* sorted unique node ids; index = position */ + int nverts; /* |V| */ + int *adj_head; /* CSR row starts, length nverts+1 */ + int *adj; /* CSR column (target vertex indices), length nedges */ + int nedges; /* |E| within the vertex set */ +} scc_graph_t; + +static int scc_id_index(const int64_t *ids, int n, int64_t id) { + int lo = 0; + int hi = n - 1; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (ids[mid] < id) { + lo = mid + 1; + } else if (ids[mid] > id) { + hi = mid - 1; + } else { + return mid; + } + } + return -1; +} + +static int cmp_int64(const void *a, const void *b) { + int64_t x = *(const int64_t *)a; + int64_t y = *(const int64_t *)b; + return (x > y) - (x < y); +} + +/* Build the CSR call graph from parallel (src,tgt) edge arrays. Returns false + * on OOM/empty. */ +static bool scc_build(const int64_t *src, const int64_t *tgt, int ecount, scc_graph_t *g) { + memset(g, 0, sizeof(*g)); + if (ecount <= 0) { + return false; + } + int64_t *all = malloc((size_t)ecount * 2 * sizeof(int64_t)); + if (!all) { + return false; + } + for (int i = 0; i < ecount; i++) { + all[2 * i] = src[i]; + all[2 * i + 1] = tgt[i]; + } + qsort(all, (size_t)ecount * 2, sizeof(int64_t), cmp_int64); + int nv = 0; + for (int i = 0; i < ecount * 2; i++) { + if (i == 0 || all[i] != all[i - 1]) { + all[nv++] = all[i]; + } + } + g->ids = all; + g->nverts = nv; + g->adj_head = calloc((size_t)nv + 1, sizeof(int)); + g->adj = malloc((size_t)ecount * sizeof(int)); + if (!g->adj_head || !g->adj) { + free(g->ids); + free(g->adj_head); + free(g->adj); + memset(g, 0, sizeof(*g)); + return false; + } + /* two-pass CSR fill */ + for (int i = 0; i < ecount; i++) { + int u = scc_id_index(g->ids, nv, src[i]); + g->adj_head[u + 1]++; + } + for (int i = 0; i < nv; i++) { + g->adj_head[i + 1] += g->adj_head[i]; + } + int *cursor = malloc((size_t)nv * sizeof(int)); + if (!cursor) { + free(g->ids); + free(g->adj_head); + free(g->adj); + memset(g, 0, sizeof(*g)); + return false; + } + for (int i = 0; i < nv; i++) { + cursor[i] = g->adj_head[i]; + } + for (int i = 0; i < ecount; i++) { + int u = scc_id_index(g->ids, nv, src[i]); + int v = scc_id_index(g->ids, nv, tgt[i]); + g->adj[cursor[u]++] = v; + } + free(cursor); + g->nedges = ecount; + return true; +} + +static void scc_free(scc_graph_t *g) { + free(g->ids); + free(g->adj_head); + free(g->adj); + memset(g, 0, sizeof(*g)); +} + +/* Iterative Tarjan. Fills comp[v] with a component id; components are numbered + * in discovery order. Returns the component count, or -1 on OOM. */ +static int scc_tarjan(const scc_graph_t *g, int *comp) { + enum { SCC_UNVISITED = -1 }; + int nv = g->nverts; + int *index = malloc((size_t)nv * sizeof(int)); + int *low = malloc((size_t)nv * sizeof(int)); + bool *on_stack = calloc((size_t)nv, sizeof(bool)); + int *tstack = malloc((size_t)nv * sizeof(int)); /* Tarjan's node stack */ + int *dfs_v = malloc((size_t)nv * sizeof(int)); /* explicit DFS: vertex */ + int *dfs_i = malloc((size_t)nv * sizeof(int)); /* explicit DFS: adj cursor */ + if (!index || !low || !on_stack || !tstack || !dfs_v || !dfs_i) { + free(index); + free(low); + free(on_stack); + free(tstack); + free(dfs_v); + free(dfs_i); + return -1; + } + for (int i = 0; i < nv; i++) { + index[i] = SCC_UNVISITED; + comp[i] = SCC_UNVISITED; + } + int counter = 0; + int tsp = 0; /* Tarjan stack pointer */ + int ncomp = 0; /* component id allocator */ + for (int s = 0; s < nv; s++) { + if (index[s] != SCC_UNVISITED) { + continue; + } + int dsp = 0; /* DFS stack pointer */ + dfs_v[dsp] = s; + dfs_i[dsp] = g->adj_head[s]; + index[s] = low[s] = counter++; + tstack[tsp++] = s; + on_stack[s] = true; + while (dsp >= 0) { + int v = dfs_v[dsp]; + if (dfs_i[dsp] < g->adj_head[v + 1]) { + int w = g->adj[dfs_i[dsp]++]; + if (index[w] == SCC_UNVISITED) { + index[w] = low[w] = counter++; + tstack[tsp++] = w; + on_stack[w] = true; + dsp++; + dfs_v[dsp] = w; + dfs_i[dsp] = g->adj_head[w]; + } else if (on_stack[w] && index[w] < low[v]) { + low[v] = index[w]; + } + } else { + /* v fully explored: it is a root iff low==index -> pop an SCC */ + if (low[v] == index[v]) { + int w; + do { + w = tstack[--tsp]; + on_stack[w] = false; + comp[w] = ncomp; + } while (w != v); + ncomp++; + } + dsp--; + if (dsp >= 0 && low[v] < low[dfs_v[dsp]]) { + low[dfs_v[dsp]] = low[v]; + } + } + } + } + free(index); + free(low); + free(on_stack); + free(tstack); + free(dfs_v); + free(dfs_i); + return ncomp; +} static bool aspect_is_valid(const char *name) { if (!name) { @@ -3990,6 +4195,25 @@ static bool aspect_is_valid(const char *name) { /* Check if an aspect is requested. NULL aspects = all. The array can contain * "all" (everything), "overview" (everything except file_tree — see * cbm_store_arch_aspect_in_overview in store.c), or the aspect name itself. */ +/* True ONLY when `name` is explicitly present in the aspects array — never via + * the no-filter default, "all", or "overview". For expensive opt-in aspects + * (cycles scans the whole call graph) that must not run on a bare call. */ +static bool aspect_explicitly_named(yyjson_val *aspects_arr, const char *name) { + if (!aspects_arr) { + return false; + } + yyjson_arr_iter iter; + yyjson_arr_iter_init(aspects_arr, &iter); + yyjson_val *val; + while ((val = yyjson_arr_iter_next(&iter)) != NULL) { + const char *s = yyjson_get_str(val); + if (s && strcmp(s, name) == 0) { + return true; + } + } + return false; +} + static bool aspect_wanted(yyjson_doc *aspects_doc, yyjson_val *aspects_arr, const char *name) { if (!aspects_arr) { return true; /* no filter = all */ @@ -4055,6 +4279,136 @@ static void arch_join_list(char *buf, size_t sz, const char **items, int n) { } } +/* Compute the circular-dependency SCCs (size > 1) of the CALLS graph. Returns + * a malloc'd array of components, each a malloc'd int64 array of member node + * ids, with sizes in *out_sizes and count in *out_ncycles; sets *scanned_edges + * and *edges_truncated. Caller frees each component + the arrays. Returns + * CBM_STORE_OK, or CBM_STORE_ERR on failure (all outs zeroed). */ +enum { ARCH_SCC_MAX_EDGES = 400000, ARCH_SCC_MAX_CYCLES = 100, ARCH_SCC_MEMBERS_SHOWN = 20 }; + +static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t ***out_members, + int **out_sizes, int *out_ncycles, int *scanned_edges, + bool *edges_truncated) { + *out_members = NULL; + *out_sizes = NULL; + *out_ncycles = 0; + *scanned_edges = 0; + *edges_truncated = false; + + int64_t *src = NULL; + int64_t *tgt = NULL; + int ecount = 0; + if (cbm_store_fetch_call_edges(store, project, ARCH_SCC_MAX_EDGES, &src, &tgt, &ecount, + edges_truncated) != CBM_STORE_OK) { + return CBM_STORE_ERR; + } + *scanned_edges = ecount; + scc_graph_t g; + if (!scc_build(src, tgt, ecount, &g)) { + free(src); + free(tgt); + return CBM_STORE_OK; /* no edges = no cycles, not an error */ + } + free(src); + free(tgt); + + int *comp = malloc((size_t)g.nverts * sizeof(int)); + if (!comp) { + scc_free(&g); + return CBM_STORE_ERR; + } + int ncomp = scc_tarjan(&g, comp); + if (ncomp < 0) { + free(comp); + scc_free(&g); + return CBM_STORE_ERR; + } + /* size per component */ + int *csize = calloc((size_t)ncomp, sizeof(int)); + if (!csize) { + free(comp); + scc_free(&g); + return CBM_STORE_ERR; + } + for (int v = 0; v < g.nverts; v++) { + csize[comp[v]]++; + } + /* collect components with size > 1 (the cycles) */ + int ncyc = 0; + for (int c = 0; c < ncomp; c++) { + if (csize[c] > 1) { + ncyc++; + } + } + if (ncyc > ARCH_SCC_MAX_CYCLES) { + ncyc = ARCH_SCC_MAX_CYCLES; + } + int64_t **members = ncyc > 0 ? calloc((size_t)ncyc, sizeof(int64_t *)) : NULL; + int *sizes = ncyc > 0 ? calloc((size_t)ncyc, sizeof(int)) : NULL; + /* map component id -> output slot (only for the first ARCH_SCC_MAX_CYCLES + * size>1 comps, in component-id order) */ + int *slot = malloc((size_t)ncomp * sizeof(int)); + if ((ncyc > 0 && (!members || !sizes)) || !slot) { + free(members); + free(sizes); + free(slot); + free(csize); + free(comp); + scc_free(&g); + return CBM_STORE_ERR; + } + int next_slot = 0; + for (int c = 0; c < ncomp; c++) { + if (csize[c] > 1 && next_slot < ncyc) { + slot[c] = next_slot; + sizes[next_slot] = csize[c]; + members[next_slot] = malloc((size_t)csize[c] * sizeof(int64_t)); + next_slot++; + } else { + slot[c] = -1; + } + } + int *fill = calloc((size_t)ncyc, sizeof(int)); + if (ncyc > 0 && !fill) { + for (int i = 0; i < ncyc; i++) { + free(members[i]); + } + free(members); + free(sizes); + free(slot); + free(csize); + free(comp); + scc_free(&g); + return CBM_STORE_ERR; + } + for (int v = 0; v < g.nverts; v++) { + int sl = slot[comp[v]]; + if (sl >= 0) { + members[sl][fill[sl]++] = g.ids[v]; + } + } + free(fill); + free(slot); + free(csize); + free(comp); + scc_free(&g); + *out_members = members; + *out_sizes = sizes; + *out_ncycles = ncyc; + return CBM_STORE_OK; +} + +/* Fetch the qualified_name for a node id, or a "#" fallback. */ +static void arch_node_qn(cbm_store_t *store, int64_t id, char *out, size_t outsz) { + cbm_node_t n = {0}; + if (cbm_store_find_node_by_id(store, id, &n) == CBM_STORE_OK && n.qualified_name) { + snprintf(out, outsz, "%s", n.qualified_name); + } else { + snprintf(out, outsz, "#%lld", (long long)id); + } + free_node_contents(&n); +} + static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); char *scope_path = cbm_mcp_get_string_arg(args, "path"); @@ -4354,6 +4708,49 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { } } + /* cycles: circular CALLS dependencies (SCCs of size > 1) — opt-in, it + * scans the whole call graph. A quotient/condensation view. */ + if (aspect_explicitly_named(aspects_arr, "cycles")) { + int64_t **members = NULL; + int *sizes = NULL; + int ncyc = 0; + int scanned = 0; + bool etrunc = false; + if (arch_compute_cycles(store, project, &members, &sizes, &ncyc, &scanned, &etrunc) == + CBM_STORE_OK) { + cbm_tree_scalar_int(&sb, "call_edges_scanned", scanned); + if (etrunc) { + cbm_tree_scalar_bool(&sb, "cycles_partial", true); + cbm_tree_scalar_str(&sb, "cycles_hint", + "call graph exceeded the scan budget; cycle list may be " + "incomplete"); + } + char hdr[CBM_SZ_128]; + snprintf(hdr, sizeof(hdr), + "cycles: %d (rows: size members; circular CALLS dependencies)\n", ncyc); + cbm_sb_append(&sb, hdr); + for (int c = 0; c < ncyc; c++) { + char row[CBM_SZ_2K]; + int off = snprintf(row, sizeof(row), " %d ", sizes[c]); + bool clipped = sizes[c] > ARCH_SCC_MEMBERS_SHOWN; + int show = clipped ? ARCH_SCC_MEMBERS_SHOWN : sizes[c]; + for (int m = 0; m < show && off < (int)sizeof(row) - 2; m++) { + char qn[CBM_SZ_512]; + arch_node_qn(store, members[c][m], qn, sizeof(qn)); + off += snprintf(row + off, sizeof(row) - off, "%s%s", m ? ";" : "", qn); + } + if (clipped && off < (int)sizeof(row) - 8) { + snprintf(row + off, sizeof(row) - off, ";+%d", sizes[c] - show); + } + cbm_sb_append(&sb, row); + cbm_sb_append(&sb, "\n"); + free(members[c]); + } + free(members); + free(sizes); + } + } + cbm_store_architecture_free(&arch); cbm_store_schema_free(&schema); if (aspects_doc) { @@ -4596,6 +4993,38 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { append_cross_repo_summary(doc, root, &schema); + /* cycles: SCCs of size > 1 in the CALLS graph (same model as tree). */ + if (aspect_explicitly_named(aspects_arr, "cycles")) { + int64_t **members = NULL; + int *sizes = NULL; + int ncyc = 0; + int scanned = 0; + bool etrunc = false; + if (arch_compute_cycles(store, project, &members, &sizes, &ncyc, &scanned, &etrunc) == + CBM_STORE_OK) { + yyjson_mut_obj_add_int(doc, root, "call_edges_scanned", scanned); + yyjson_mut_obj_add_bool(doc, root, "cycles_partial", etrunc); + yyjson_mut_val *cyc = yyjson_mut_arr(doc); + for (int c = 0; c < ncyc; c++) { + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_int(doc, o, "size", sizes[c]); + yyjson_mut_val *mem = yyjson_mut_arr(doc); + int show = sizes[c] < ARCH_SCC_MEMBERS_SHOWN ? sizes[c] : ARCH_SCC_MEMBERS_SHOWN; + for (int m = 0; m < show; m++) { + char qn[CBM_SZ_512]; + arch_node_qn(store, members[c][m], qn, sizeof(qn)); + yyjson_mut_arr_add_strcpy(doc, mem, qn); + } + yyjson_mut_obj_add_val(doc, o, "members", mem); + yyjson_mut_arr_add_val(cyc, o); + free(members[c]); + } + yyjson_mut_obj_add_val(doc, root, "cycles", cyc); + free(members); + free(sizes); + } + } + char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); cbm_store_architecture_free(&arch); @@ -6798,6 +7227,16 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, int start = node->start_line > 0 ? node->start_line : SKIP_ONE; int end = node->end_line > start ? node->end_line : start + SNIPPET_DEFAULT_LINES; + /* Context-bomb guard: a structural node (Module/File) spans its whole file, + * so an unclipped read returned the ENTIRE source — a field-eval agent that + * fell back to a Module snippet pulled 400KB in one call. Cap the line span + * (far above any real function) and flag it; the exact range is still in + * start_line/end_line for a targeted re-read. */ + bool snippet_clipped = false; + if (end - start + 1 > MCP_SNIPPET_MAX_LINES) { + end = start + MCP_SNIPPET_MAX_LINES - 1; + snippet_clipped = true; + } char *abs_path = NULL; char *source = resolve_snippet_source(root_path, node->file_path, start, end, &abs_path); @@ -6819,6 +7258,10 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, yyjson_mut_obj_add_str(doc, root_obj, "file_path", display_path); yyjson_mut_obj_add_int(doc, root_obj, "start_line", start); yyjson_mut_obj_add_int(doc, root_obj, "end_line", end); + if (snippet_clipped) { + yyjson_mut_obj_add_bool(doc, root_obj, "source_clipped", true); + yyjson_mut_obj_add_int(doc, root_obj, "clipped_at_lines", MCP_SNIPPET_MAX_LINES); + } if (source) { char *safe_source = sanitize_utf8_lossy(source); @@ -8062,25 +8505,101 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { /* ── detect_changes ───────────────────────────────────────────── */ -/* Find symbols defined in a file and add them to the impacted array. */ -static void detect_add_impacted_symbols(cbm_store_t *store, const char *project, const char *file, - yyjson_mut_doc *doc, yyjson_mut_val *impacted) { +/* Collect BFS seed ids: every symbol DEFINED in a changed file (everything but + * the structural container labels — those have no CALLS edges). These anchor + * the multi-source impact traversal. */ +static void detect_collect_seeds(cbm_store_t *store, const char *project, const char *file, + int64_t **seeds, int *n, int *cap) { cbm_node_t *nodes = NULL; int ncount = 0; cbm_store_find_nodes_by_file(store, project, file, &nodes, &ncount); for (int i = 0; i < ncount; i++) { - if (nodes[i].label && strcmp(nodes[i].label, "File") != 0 && - strcmp(nodes[i].label, "Folder") != 0 && strcmp(nodes[i].label, "Project") != 0) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "name", nodes[i].name ? nodes[i].name : ""); - yyjson_mut_obj_add_strcpy(doc, item, "label", nodes[i].label); - yyjson_mut_obj_add_strcpy(doc, item, "file", file); - yyjson_mut_arr_add_val(impacted, item); + const char *lb = nodes[i].label; + if (lb && strcmp(lb, "File") != 0 && strcmp(lb, "Folder") != 0 && + strcmp(lb, "Project") != 0 && strcmp(lb, "Module") != 0 && strcmp(lb, "Package") != 0 && + strcmp(lb, "Section") != 0) { + if (*n >= *cap) { + *cap = *cap ? *cap * 2 : 16; + *seeds = safe_realloc(*seeds, (size_t)*cap * sizeof(int64_t)); + } + (*seeds)[(*n)++] = nodes[i].id; } } cbm_store_free_nodes(nodes, ncount); } +/* Module key for the impacted rollup = the first TWO path segments + * ("src/mcp/mcp.c" -> "src/mcp"), a quotient of the blast radius coarse enough + * to fit yet specific enough to localize (one segment collapses a whole tree + * to "src"). Falls back to one segment, then the whole path. */ +static void detect_module_of(const char *file, char *out, size_t outsz) { + if (!file || !file[0]) { + snprintf(out, outsz, "(root)"); + return; + } + const char *s1 = strchr(file, '/'); + if (!s1) { + snprintf(out, outsz, "%s", file); + return; + } + const char *s2 = strchr(s1 + 1, '/'); + size_t len = s2 ? (size_t)(s2 - file) : strlen(file); + if (len >= outsz) { + len = outsz - 1; + } + memcpy(out, file, len); + out[len] = '\0'; +} + +/* Emit the impacted set as a grouped tree leg: rows grouped under their shared + * (qn-prefix, file), `name label hop` per row. At most `limit` rows are listed + * (the visited array is hop-ordered, so the closest — highest-signal — impact + * shows first); impacted_total always carries the exact full count, and + * `impacted_shown < impacted_total` is the honest truncation signal. The + * module rollup (emitted by the caller) stays complete regardless. */ +static void detect_emit_impacted_tree(cbm_sb_t *sb, cbm_traverse_result_t *tr, int limit) { + cbm_tree_scalar_int(sb, "impacted_total", tr->visited_count); + int shown = tr->visited_count < limit ? tr->visited_count : limit; + /* qn order for stable grouping, but keep hop-closeness: sort by (hop) is + * lost under qn sort, so group AFTER selecting the nearest `shown` rows — + * the visited array is already (hop,id)-ordered from the BFS. */ + char hdr[CBM_SZ_128]; + snprintf(hdr, sizeof(hdr), + "impacted_shown: %d\nimpacted: %d (rows: name label hop; qn = group prefix + \".\" " + "+ name; nearest hops first)\n", + shown, shown); + cbm_sb_append(sb, hdr); + if (shown > 1) { + qsort(tr->visited, (size_t)shown, sizeof(cbm_node_hop_t), tree_hop_cmp_qn); + } + char cur_group[CBM_SZ_1K] = ""; + for (int i = 0; i < shown; i++) { + const char *qn = + tr->visited[i].node.qualified_name ? tr->visited[i].node.qualified_name : ""; + const char *file = tr->visited[i].node.file_path ? tr->visited[i].node.file_path : ""; + size_t plen = sg_qn_prefix_len(qn); + char group[CBM_SZ_1K]; + snprintf(group, sizeof(group), "%.*s (%s)", (int)plen, qn, file); + if (strcmp(group, cur_group) != 0) { + snprintf(cur_group, sizeof(cur_group), "%s", group); + cbm_sb_append(sb, group); + cbm_sb_append(sb, ":\n"); + } + char row[CBM_SZ_512]; + snprintf(row, sizeof(row), " %s %s %d\n", plen ? qn + plen + 1 : qn, + tr->visited[i].node.label ? tr->visited[i].node.label : "", tr->visited[i].hop); + cbm_sb_append(sb, row); + } + if (shown < tr->visited_count) { + char more[CBM_SZ_256]; + snprintf(more, sizeof(more), + "impacted_omitted: %d (see impacted_modules for the full rollup; raise 'limit' " + "or lower 'depth' to see specifics)\n", + tr->visited_count - shown); + cbm_sb_append(sb, more); + } +} + static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); char *base_branch = cbm_mcp_get_string_arg(args, "base_branch"); @@ -8178,19 +8697,45 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result(errmsg, true); } - yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root_obj = yyjson_mut_obj(doc); - yyjson_mut_doc_set_root(doc, root_obj); - - yyjson_mut_val *changed = yyjson_mut_arr(doc); - yyjson_mut_val *impacted = yyjson_mut_arr(doc); - /* resolve_store already called via get_project_root above */ cbm_store_t *store = srv->store; - char line[CBM_SZ_1K]; + /* Direction of impact. Default inbound = the BLAST RADIUS: the transitive + * CALLERS of the changed symbols, which may need review. outbound = what + * the changed code depends on; both = union. */ + char *direction = cbm_mcp_get_string_arg(args, "direction"); + if (!direction) { + direction = heap_strdup("inbound"); + } + if (strcmp(direction, "inbound") != 0 && strcmp(direction, "outbound") != 0 && + strcmp(direction, "both") != 0) { + free(direction); + direction = heap_strdup("inbound"); + } + char *fmt = cbm_mcp_get_string_arg(args, "format"); + bool legacy_json = fmt && strcmp(fmt, "json") == 0; + free(fmt); + + /* Per-symbol impacted-row display cap (the module rollup stays complete). + * impacted_total always reports the true count, so this never hides scale. */ + int imp_limit = cbm_mcp_get_int_arg(args, "limit", MCP_DEFAULT_IMPACT_LIMIT); + if (imp_limit < 1) { + imp_limit = 1; + } + if (imp_limit > MCP_BFS_LIMIT_MAX) { + imp_limit = MCP_BFS_LIMIT_MAX; + } + + /* Collect changed file paths into a C array (drives seeds, the rollup, and + * both output encodings). */ + char **files = NULL; int file_count = 0; + int file_cap = 0; + int64_t *seeds = NULL; + int seed_count = 0; + int seed_cap = 0; + char line[CBM_SZ_1K]; while (fgets(line, sizeof(line), fp)) { size_t len = strlen(line); while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) { @@ -8199,59 +8744,213 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { if (len == 0) { continue; } - - /* `git status --porcelain` prefixes each path with a two-character - * status code and a space ("?? path", "A path", " M path"). The two - * `git diff --name-only` sources emit bare paths. Strip the porcelain - * prefix when present so all three sources yield clean paths; for a - * rename ("R old -> new") keep the post-arrow destination path. */ + /* Strip the `git status --porcelain` 2-char code + space; for a rename + * ("R old -> new") keep the destination path. */ char *path_line = line; if (len > PAIR_LEN && line[PAIR_LEN] == ' ' && strchr(" MADRCU?!", line[0]) && strchr(" MADRCU?!", line[1])) { path_line = line + PAIR_LEN + SKIP_ONE; char *arrow = strstr(path_line, " -> "); if (arrow) { - enum { ARROW_LEN = 4 }; /* length of " -> " */ + enum { ARROW_LEN = 4 }; path_line = arrow + ARROW_LEN; } } if (path_line[0] == '\0') { continue; } - - yyjson_mut_arr_add_strcpy(doc, changed, path_line); - file_count++; - + /* Dedup: the three git sources are sorted+unioned on POSIX but not on + * Windows (separate commands), and a path can repeat. */ + bool dup = false; + for (int i = 0; i < file_count; i++) { + if (strcmp(files[i], path_line) == 0) { + dup = true; + break; + } + } + if (dup) { + continue; + } + if (file_count >= file_cap) { + file_cap = file_cap ? file_cap * 2 : 16; + files = safe_realloc(files, (size_t)file_cap * sizeof(char *)); + } + files[file_count++] = heap_strdup(path_line); if (want_symbols) { - detect_add_impacted_symbols(store, project, path_line, doc, impacted); + detect_collect_seeds(store, project, path_line, &seeds, &seed_count, &seed_cap); } } int git_status = cbm_pclose(fp); - bool is_error = false; - if (git_status != 0 && file_count == 0) { - char hint_buf[CBM_SZ_256]; - snprintf(hint_buf, sizeof(hint_buf), - "git diff exited with status %d. Check that branch '%s' exists.", git_status, + /* merge-base SHA: the exact commit the diff is measured against, so the + * result is reproducible even as base_branch advances. Best-effort. */ + char merge_base[64] = ""; + { + char mbcmd[CBM_SZ_2K]; +#ifdef _WIN32 + snprintf(mbcmd, sizeof(mbcmd), "git -C \"%s\" merge-base \"%s\" HEAD 2>NUL", root_path, base_branch); - yyjson_mut_obj_add_strcpy(doc, root_obj, "hint", hint_buf); - is_error = true; +#else + snprintf(mbcmd, sizeof(mbcmd), "git -C '%s' merge-base '%s' HEAD 2>/dev/null", root_path, + base_branch); +#endif + FILE *mbfp = cbm_popen(mbcmd, "r"); + if (mbfp) { + if (fgets(merge_base, sizeof(merge_base), mbfp)) { + size_t l = strlen(merge_base); + while (l > 0 && (merge_base[l - 1] == '\n' || merge_base[l - 1] == '\r')) { + merge_base[--l] = '\0'; + } + } + (void)cbm_pclose(mbfp); + } } - yyjson_mut_obj_add_val(doc, root_obj, "changed_files", changed); - yyjson_mut_obj_add_int(doc, root_obj, "changed_count", file_count); - yyjson_mut_obj_add_val(doc, root_obj, "impacted_symbols", impacted); - yyjson_mut_obj_add_int(doc, root_obj, "depth", depth); + /* The impact traversal: ONE multi-source BFS over all seeds. */ + cbm_traverse_result_t impact = {0}; + bool truncated = false; + if (want_symbols && seed_count > 0) { + (void)cbm_store_bfs_multi(store, seeds, seed_count, direction, NULL, 0, depth, + MCP_BFS_LIMIT_MAX, &impact, &truncated); + } - char *json = yy_doc_to_str(doc); - yyjson_mut_doc_free(doc); + bool is_error = (git_status != 0 && file_count == 0); + char *out_str = NULL; + + if (!legacy_json) { + cbm_sb_t sb; + cbm_sb_init(&sb); + cbm_tree_scalar_str(&sb, "base", base_branch); + if (merge_base[0]) { + cbm_tree_scalar_str(&sb, "merge_base", merge_base); + } + cbm_tree_scalar_str(&sb, "direction", direction); + if (is_error) { + char hint_buf[CBM_SZ_256]; + snprintf(hint_buf, sizeof(hint_buf), + "git diff exited with status %d. Check that branch '%s' exists.", git_status, + base_branch); + cbm_tree_scalar_str(&sb, "hint", hint_buf); + } + /* changed files (the git result) */ + char cf[CBM_SZ_64]; + snprintf(cf, sizeof(cf), "changed_files: %d\n", file_count); + cbm_sb_append(&sb, cf); + for (int i = 0; i < file_count; i++) { + cbm_sb_append(&sb, " "); + cbm_sb_append(&sb, files[i]); + cbm_sb_append(&sb, "\n"); + } + cbm_tree_scalar_int(&sb, "seed_symbols", seed_count); + if (want_symbols) { + detect_emit_impacted_tree(&sb, &impact, imp_limit); + /* module rollup: a quotient view of the blast radius */ + if (impact.visited_count > 0) { + cbm_sb_append(&sb, "impacted_modules: (rows: module count)\n"); + enum { MODCAP = 256 }; + char mods[MODCAP][CBM_SZ_128]; + int mcnt[MODCAP]; + int nmods = 0; + int overflow = 0; /* symbols whose module didn't fit MODCAP */ + for (int i = 0; i < impact.visited_count; i++) { + char m[CBM_SZ_128]; + detect_module_of(impact.visited[i].node.file_path, m, sizeof(m)); + int j = 0; + for (; j < nmods; j++) { + if (strcmp(mods[j], m) == 0) { + mcnt[j]++; + break; + } + } + if (j == nmods) { + if (nmods < MODCAP) { + snprintf(mods[nmods], sizeof(mods[nmods]), "%s", m); + mcnt[nmods] = 1; + nmods++; + } else { + overflow++; /* never silently dropped — surfaced below */ + } + } + } + for (int j = 0; j < nmods; j++) { + char mrow[CBM_SZ_256]; + snprintf(mrow, sizeof(mrow), " %s %d\n", mods[j], mcnt[j]); + cbm_sb_append(&sb, mrow); + } + if (overflow > 0) { + char orow[CBM_SZ_128]; + snprintf(orow, sizeof(orow), " (other) %d\n", overflow); + cbm_sb_append(&sb, orow); + } + } + if (truncated) { + cbm_tree_scalar_bool(&sb, "truncated", true); + cbm_tree_scalar_str(&sb, "hint", + "impact hit the safety ceiling — narrow with a lower " + "'depth' or a smaller diff"); + } + } + out_str = cbm_sb_finish(&sb); + } else { + /* format:"json" = json-stringified tree: same model, structured. */ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root_obj = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root_obj); + yyjson_mut_obj_add_strcpy(doc, root_obj, "base", base_branch); + if (merge_base[0]) { + yyjson_mut_obj_add_strcpy(doc, root_obj, "merge_base", merge_base); + } + yyjson_mut_obj_add_strcpy(doc, root_obj, "direction", direction); + yyjson_mut_val *cf = yyjson_mut_arr(doc); + for (int i = 0; i < file_count; i++) { + yyjson_mut_arr_add_strcpy(doc, cf, files[i]); + } + yyjson_mut_obj_add_val(doc, root_obj, "changed_files", cf); + yyjson_mut_obj_add_int(doc, root_obj, "seed_symbols", seed_count); + yyjson_mut_obj_add_int(doc, root_obj, "impacted_total", impact.visited_count); + int imp_shown = impact.visited_count < imp_limit ? impact.visited_count : imp_limit; + yyjson_mut_obj_add_int(doc, root_obj, "impacted_shown", imp_shown); + yyjson_mut_val *imp = yyjson_mut_arr(doc); + for (int i = 0; i < imp_shown; i++) { + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy( + doc, o, "qn", + impact.visited[i].node.qualified_name ? impact.visited[i].node.qualified_name : ""); + yyjson_mut_obj_add_strcpy( + doc, o, "label", impact.visited[i].node.label ? impact.visited[i].node.label : ""); + yyjson_mut_obj_add_strcpy( + doc, o, "file", + impact.visited[i].node.file_path ? impact.visited[i].node.file_path : ""); + yyjson_mut_obj_add_int(doc, o, "hop", impact.visited[i].hop); + yyjson_mut_arr_add_val(imp, o); + } + yyjson_mut_obj_add_val(doc, root_obj, "impacted", imp); + yyjson_mut_obj_add_bool(doc, root_obj, "truncated", truncated); + if (is_error) { + char hint_buf[CBM_SZ_256]; + snprintf(hint_buf, sizeof(hint_buf), + "git diff exited with status %d. Check that branch '%s' exists.", git_status, + base_branch); + yyjson_mut_obj_add_strcpy(doc, root_obj, "hint", hint_buf); + } + out_str = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + } + + cbm_store_traverse_free(&impact); + for (int i = 0; i < file_count; i++) { + free(files[i]); + } + free(files); + free(seeds); + free(direction); free(root_path); free(project); free(base_branch); free(scope); - char *result = cbm_mcp_text_result(json, is_error); - free(json); + char *result = cbm_mcp_text_result(out_str, is_error); + free(out_str); return result; } diff --git a/src/store/store.c b/src/store/store.c index a9e86cb34..7b966051a 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -1846,6 +1846,76 @@ static void bind_proj_and_type(sqlite3_stmt *stmt, const void *data) { bind_text(stmt, ST_COL_2, b->type); } +int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, + int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated) { + if (out_src) { + *out_src = NULL; + } + if (out_tgt) { + *out_tgt = NULL; + } + if (count) { + *count = 0; + } + if (truncated) { + *truncated = false; + } + if (!s || !s->db || !project || !out_src || !out_tgt || !count) { + return CBM_STORE_ERR; + } + /* CALLS edges whose BOTH endpoints are callable defs — the call graph the + * SCC pass condenses. Ordered for determinism. LIMIT max_edges + 1 so a + * full result is distinguishable from a truncated one. */ + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT e.source_id, e.target_id FROM edges e " + "JOIN nodes ns ON ns.id = e.source_id " + "JOIN nodes nt ON nt.id = e.target_id " + "WHERE e.project = ?1 AND e.type = 'CALLS' " + " AND ns.label IN ('Function','Method') " + " AND nt.label IN ('Function','Method') " + "ORDER BY e.source_id, e.target_id LIMIT ?2;", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "fetch_call_edges prepare"); + return CBM_STORE_ERR; + } + bind_text(stmt, SKIP_ONE, project); + sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND); + + int cap = ST_INIT_CAP_16; + int n = 0; + int64_t *src = malloc((size_t)cap * sizeof(int64_t)); + int64_t *tgt = malloc((size_t)cap * sizeof(int64_t)); + int scan_rc; + while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) { + if (max_edges > 0 && n >= max_edges) { + if (truncated) { + *truncated = true; /* the sentinel row proves there are more */ + } + break; + } + if (n >= cap) { + cap *= ST_GROWTH; + src = safe_realloc(src, (size_t)cap * sizeof(int64_t)); + tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t)); + } + src[n] = sqlite3_column_int64(stmt, 0); + tgt[n] = sqlite3_column_int64(stmt, 1); + n++; + } + sqlite3_finalize(stmt); + if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) { + free(src); + free(tgt); + store_set_error_sqlite(s, "fetch_call_edges scan"); + return CBM_STORE_ERR; + } + *out_src = src; + *out_tgt = tgt; + *count = n; + return CBM_STORE_OK; +} + int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count) { bind_id_t b = {source_id}; @@ -3833,6 +3903,132 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const return CBM_STORE_OK; } +/* Multi-source BFS: one recursive CTE anchored on ALL seeds (via a temp + * table — never a per-seed loop, which re-walks overlapping hub subgraphs + * seed_count times). Semantics for impact analysis: + * - shortest-path MIN(hop) across the whole seed set; + * - SEEDS ARE EXCLUDED from the result: a seed reached from another seed + * (changed files call each other constantly) is not "impact"; + * - uncapped counting up to max_results as a memory-safety ceiling only — + * *truncated reports when it was hit (never a silent cap); + * - canonical (hop, id) order. + * No root node and no edge collection — impact wants the reached set. */ +int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count, + const char *direction, const char **edge_types, int edge_type_count, + int max_depth, int max_results, cbm_traverse_result_t *out, + bool *truncated) { + memset(out, 0, sizeof(*out)); + if (truncated) { + *truncated = false; + } + if (!s || !s->db || !seed_ids || seed_count <= 0) { + return CBM_STORE_ERR; + } + + if (sqlite3_exec(s->db, + "CREATE TEMP TABLE IF NOT EXISTS bfs_seeds (id INTEGER PRIMARY KEY);" + "DELETE FROM bfs_seeds;", + NULL, NULL, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "bfs_multi seeds"); + return CBM_STORE_ERR; + } + sqlite3_stmt *ins = NULL; + if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_seeds(id) VALUES (?1)", CBM_NOT_FOUND, + &ins, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "bfs_multi seed insert"); + return CBM_STORE_ERR; + } + for (int i = 0; i < seed_count; i++) { + sqlite3_reset(ins); + sqlite3_bind_int64(ins, SKIP_ONE, seed_ids[i]); + (void)sqlite3_step(ins); + } + sqlite3_finalize(ins); + + char types_clause[CBM_SZ_512]; + bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause)); + + const char *join_cond; + const char *next_id; + bool is_inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0); + if (is_inbound) { + join_cond = "e.target_id = bfs.node_id"; + next_id = "e.source_id"; + } else { + join_cond = "e.source_id = bfs.node_id"; + next_id = "e.target_id"; + } + + char sql[CBM_SZ_4K]; + snprintf(sql, sizeof(sql), + "WITH RECURSIVE bfs(node_id, hop) AS (" + " SELECT id, 0 FROM bfs_seeds" + " UNION" + " SELECT %s, bfs.hop + 1" + " FROM bfs" + " JOIN edges e ON %s" + " WHERE e.type IN (%s) AND bfs.hop < %d" + ")" + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " + "n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop " + "FROM bfs " + "JOIN nodes n ON n.id = bfs.node_id " + /* hop > 0 excludes the anchors themselves; the NOT IN excludes a + * seed REACHED from another seed at hop > 0 (leak-back). */ + "WHERE bfs.hop > 0 AND bfs.node_id NOT IN (SELECT id FROM bfs_seeds) " + "GROUP BY n.id " + "ORDER BY hop, n.id " + "LIMIT %d;", + next_id, join_cond, types_clause, max_depth, max_results + SKIP_ONE); + + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "bfs_multi prepare"); + return CBM_STORE_ERR; + } + if (edge_type_count > 0) { + for (int i = 0; i < edge_type_count; i++) { + bind_text(stmt, i + SKIP_ONE, edge_types[i]); + } + } else { + bind_text(stmt, SKIP_ONE, "CALLS"); + } + + int cap = ST_INIT_CAP_16; + int n = 0; + cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t)); + int scan_rc16; + while ((scan_rc16 = sqlite3_step(stmt)) == SQLITE_ROW) { + if (n > max_results) { + break; /* ceiling + 1 row fetched: report truncation below */ + } + if (n >= cap) { + cap *= ST_GROWTH; + visited = safe_realloc(visited, cap * sizeof(cbm_node_hop_t)); + } + scan_node(stmt, &visited[n].node); + visited[n].hop = sqlite3_column_int(stmt, ST_COL_9); + n++; + } + bool aborted = (scan_rc16 != SQLITE_DONE && scan_rc16 != SQLITE_ROW); + if (n > max_results) { + /* Free the sentinel row and flag the ceiling. */ + n = max_results; + cbm_node_free_fields(&visited[n].node); + if (truncated) { + *truncated = true; + } + } + sqlite3_finalize(stmt); + out->visited = visited; + out->visited_count = n; + if (aborted) { + store_set_error_sqlite(s, "bfs_multi row scan aborted"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + void cbm_store_traverse_free(cbm_traverse_result_t *out) { if (!out) { return; diff --git a/src/store/store.h b/src/store/store.h index d088139ea..373440474 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -368,6 +368,13 @@ int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e); /* Insert edges in batch. */ int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count); +/* Fetch all CALLS edges among Function/Method nodes for a project as parallel + * (source_id, target_id) arrays (caller frees both). For SCC / cycle analysis. + * Stops at max_edges and sets *truncated — never a silent cap. Returns + * CBM_STORE_OK (or _ERR); *count is the number returned. */ +int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges, + int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated); + /* Find edges by source node. */ int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count); @@ -500,6 +507,15 @@ void cbm_store_search_free(cbm_search_output_t *out); int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types, int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out); +/* Multi-source BFS from ALL seed ids at once (one CTE, temp-table anchored). + * Seeds are EXCLUDED from the result (impact semantics); MIN(hop) across the + * seed set; canonical (hop,id) order; *truncated set when the max_results + * memory-safety ceiling was hit (counting is otherwise uncapped). */ +int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count, + const char *direction, const char **edge_types, int edge_type_count, + int max_depth, int max_results, cbm_traverse_result_t *out, + bool *truncated); + /* Free a traverse result's allocated memory. */ void cbm_store_traverse_free(cbm_traverse_result_t *out); diff --git a/tests/test_incremental.c b/tests/test_incremental.c index 00c2ea53a..72250707b 100644 --- a/tests/test_incremental.c +++ b/tests/test_incremental.c @@ -1860,11 +1860,35 @@ TEST(tool_detect_changes_default) { double ms; char *r = call_tool_timed("detect_changes", &ms, "{\"project\":\"%s\"}", g_project); TOOL_OK(r, ms); - /* Must have changed_files array and changed_count */ - ASSERT(resp_has_key(r, "changed_files")); - ASSERT(resp_has_key(r, "changed_count")); - ASSERT(resp_has_key(r, "impacted_symbols")); - ASSERT(resp_has_key(r, "depth")); + /* New tree contract: base + direction scalars, a changed_files section, + * and the seed/impact accounting. (The old changed_count/impacted_symbols/ + * depth keys are gone — impact is now a real traversal, not bare names.) */ + ASSERT(strstr(r, "changed_files:") != NULL); + ASSERT(strstr(r, "direction:") != NULL); + ASSERT(strstr(r, "seed_symbols:") != NULL); + free(r); + PASS(); +} + +/* The blast radius is a REAL traversal now: default inbound gives transitive + * callers of the changed symbols with an exact impacted_total and a module + * rollup. Fixture diff may be empty (shallow clone at HEAD) — the sections and + * their accounting must still be present and internally consistent. */ +TEST(tool_detect_changes_impact_shape) { + double ms; + char *r = call_tool_timed("detect_changes", &ms, + "{\"project\":\"%s\",\"base_branch\":\"HEAD\"}", g_project); + TOOL_OK(r, ms); + ASSERT(strstr(r, "direction: inbound") != NULL); /* default = blast radius */ + ASSERT(strstr(r, "seed_symbols:") != NULL); + /* format:"json" returns the same model as structured JSON. */ + free(r); + r = call_tool_timed("detect_changes", &ms, + "{\"project\":\"%s\",\"base_branch\":\"HEAD\",\"format\":\"json\"}", + g_project); + TOOL_OK(r, ms); + ASSERT(resp_has_key(r, "impacted_total")); + ASSERT(resp_has_key(r, "direction")); free(r); PASS(); } @@ -3100,6 +3124,7 @@ SUITE(incremental) { /* Phase 15: detect_changes */ RUN_TEST(tool_detect_changes_default); + RUN_TEST(tool_detect_changes_impact_shape); RUN_TEST(tool_detect_changes_custom_branch); RUN_TEST(tool_detect_changes_since); RUN_TEST(tool_detect_changes_since_precedence); diff --git a/tests/test_integration.c b/tests/test_integration.c index a63f9e5d4..aa5ad5187 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -633,6 +633,125 @@ TEST(store_bfs_edges_survive_large_visited_set) { PASS(); } +/* Multi-source BFS is the substrate for detect_changes impact analysis. Its + * contract (challenger's flagged traps): (1) ONE traversal over ALL seeds, + * not seed_count separate walks; (2) seeds EXCLUDED from the result even when + * reachable from another seed (changed files call each other — that is not + * "downstream impact"); (3) MIN(hop) across the whole seed set; (4) uncapped + * counting up to the memory ceiling, which sets *truncated when hit. Fixture: + * two seeds A, B; A->mid->leaf, B->leaf (leaf is hop 1 from B, hop 2 from A), + * and A->B directly (B reachable from A). Impact set must be {mid, leaf} with + * leaf at hop 1, and must NOT contain A or B. */ +TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "impact", "/tmp/impact"), CBM_STORE_OK); + + int64_t ids[4]; + const char *names[4] = {"A", "B", "mid", "leaf"}; + for (int i = 0; i < 4; i++) { + char qn[32]; + snprintf(qn, sizeof(qn), "impact.%s", names[i]); + cbm_node_t n = {.project = "impact", + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "m.c", + .start_line = 1, + .end_line = 5}; + ids[i] = cbm_store_upsert_node(s, &n); + ASSERT_GT(ids[i], 0); + } + int64_t A = ids[0]; + int64_t B = ids[1]; + int64_t mid = ids[2]; + int64_t leaf = ids[3]; + struct { + int64_t from; + int64_t to; + } edges[] = {{A, mid}, {mid, leaf}, {B, leaf}, {A, B}}; + for (size_t i = 0; i < sizeof(edges) / sizeof(edges[0]); i++) { + cbm_edge_t e = {.project = "impact", + .source_id = edges[i].from, + .target_id = edges[i].to, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &e), 0); + } + + int64_t seeds[2] = {A, B}; + cbm_traverse_result_t tr = {0}; + bool truncated = true; + ASSERT_EQ(cbm_store_bfs_multi(s, seeds, 2, "outbound", NULL, 0, 5, 100, &tr, &truncated), + CBM_STORE_OK); + ASSERT_FALSE(truncated); + + /* Impact set = {mid, leaf}; A and B (seeds) excluded even though B is + * reachable from A. */ + ASSERT_EQ(tr.visited_count, 2); + int seen_mid = 0; + int seen_leaf = 0; + int leaf_hop = -1; + for (int i = 0; i < tr.visited_count; i++) { + int64_t id = tr.visited[i].node.id; + ASSERT_TRUE(id != A && id != B); /* seeds never in the result */ + if (id == mid) { + seen_mid = 1; + } + if (id == leaf) { + seen_leaf = 1; + leaf_hop = tr.visited[i].hop; + } + } + ASSERT_TRUE(seen_mid && seen_leaf); + ASSERT_EQ(leaf_hop, 1); /* MIN(hop): 1 from B, not 2 from A */ + cbm_store_traverse_free(&tr); + cbm_store_close(s); + PASS(); +} + +/* The memory-safety ceiling reports truncation instead of silently capping. + * A star of N callees from one seed, ceiling = N/2, must return exactly N/2 + * rows with *truncated = true. */ +TEST(store_bfs_multi_reports_truncation_at_ceiling) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "cap", "/tmp/cap"), CBM_STORE_OK); + cbm_node_t hub = {.project = "cap", + .label = "Function", + .name = "hub", + .qualified_name = "cap.hub", + .file_path = "h.c", + .start_line = 1, + .end_line = 2}; + int64_t hub_id = cbm_store_upsert_node(s, &hub); + ASSERT_GT(hub_id, 0); + enum { N = 40, CEIL = 20 }; + for (int i = 0; i < N; i++) { + char qn[32]; + snprintf(qn, sizeof(qn), "cap.c%02d", i); + cbm_node_t n = {.project = "cap", + .label = "Function", + .name = qn + 4, + .qualified_name = qn, + .file_path = "c.c", + .start_line = 1, + .end_line = 2}; + int64_t nid = cbm_store_upsert_node(s, &n); + ASSERT_GT(nid, 0); + cbm_edge_t e = {.project = "cap", .source_id = hub_id, .target_id = nid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(s, &e), 0); + } + cbm_traverse_result_t tr = {0}; + bool truncated = false; + ASSERT_EQ(cbm_store_bfs_multi(s, &hub_id, 1, "outbound", NULL, 0, 5, CEIL, &tr, &truncated), + CBM_STORE_OK); + ASSERT_EQ(tr.visited_count, CEIL); + ASSERT_TRUE(truncated); + cbm_store_traverse_free(&tr); + cbm_store_close(s); + PASS(); +} + /* #411: index_repository silently drops entire subtrees with no record. * Moderate/fast mode applies FAST_SKIP_DIRS (tools/scripts/bin/docs/...) and ALL * modes apply ALWAYS_SKIP_DIRS (node_modules/...), so files are excluded from the @@ -721,6 +840,8 @@ SUITE(integration) { RUN_TEST(integ_store_find_by_file); RUN_TEST(integ_store_bfs_traversal); RUN_TEST(store_bfs_edges_survive_large_visited_set); + RUN_TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop); + RUN_TEST(store_bfs_multi_reports_truncation_at_ceiling); /* Pipeline API tests (no db needed) */ RUN_TEST(integ_pipeline_fqn_compute); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index bfccf786b..27d65fe9f 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -532,7 +532,7 @@ TEST(mcp_get_architecture_aspects_schema_enum_pr560) { static const char *expected[] = {"all", "overview", "structure", "dependencies", "routes", "languages", "packages", "entry_points", "hotspots", "boundaries", "layers", "file_tree", - "clusters"}; + "clusters", "cycles"}; size_t expected_count = sizeof(expected) / sizeof(expected[0]); ASSERT_EQ(yyjson_arr_size(enum_arr), expected_count); for (size_t i = 0; i < expected_count; i++) { @@ -1090,6 +1090,133 @@ static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz); static void cleanup_snippet_dir(const char *tmp_dir); static char *extract_text_content(const char *mcp_result); +/* SCC condensation (get_architecture aspect "cycles"): a 3-function CALLS + * cycle A->B->C->A must be reported as one circular dependency of size 3 with + * all three members; a separate acyclic chain (D->E) must NOT appear. The + * aspect is opt-in — a default get_architecture call must NOT compute it. */ +TEST(tool_get_architecture_cycles_detects_scc) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "cycproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/cyc"); + + const char *names[5] = {"A", "B", "C", "D", "E"}; + int64_t id[5]; + for (int i = 0; i < 5; i++) { + char qn[32]; + snprintf(qn, sizeof(qn), "cycproj.m.%s", names[i]); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = names[i], + .qualified_name = qn, + .file_path = "m.c", + .start_line = i + 1, + .end_line = i + 2}; + id[i] = cbm_store_upsert_node(st, &n); + ASSERT_GT(id[i], 0); + } + /* cycle A->B->C->A, plus acyclic D->E */ + struct { + int f; + int t; + } e[] = {{0, 1}, {1, 2}, {2, 0}, {3, 4}}; + for (size_t i = 0; i < sizeof(e) / sizeof(e[0]); i++) { + cbm_edge_t ed = { + .project = proj, .source_id = id[e[i].f], .target_id = id[e[i].t], .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &ed), 0); + } + + /* opt-in cycles aspect */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"," + "\"aspects\":[\"cycles\"]}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "cycles: 1")); /* exactly one SCC of size>1 */ + ASSERT_NOT_NULL(strstr(inner, "cycproj.m.A")); + ASSERT_NOT_NULL(strstr(inner, "cycproj.m.B")); + ASSERT_NOT_NULL(strstr(inner, "cycproj.m.C")); + ASSERT_NULL(strstr(inner, "cycproj.m.D")); /* acyclic node not in any cycle */ + free(inner); + free(resp); + + /* default call (no aspects) must NOT run the scan. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NULL(strstr(inner, "cycles:")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Context-bomb guard: get_code_snippet on a whole-file node (a Module/File + * span) used to read the ENTIRE file into one response — a field-eval agent + * that fell back to a Module snippet pulled ~400KB in a single call. The read + * must clip at MCP_SNIPPET_MAX_LINES and flag source_clipped, while the exact + * start/end range stays in the response for a targeted re-read. */ +TEST(tool_get_code_snippet_clips_whole_file_node) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_snipcap_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char proj_dir[512]; + snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp); + cbm_mkdir(proj_dir); + char src_path[600]; + snprintf(src_path, sizeof(src_path), "%s/big.py", proj_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + enum { BIG_LINES = 2000 }; + for (int i = 0; i < BIG_LINES; i++) { + fprintf(fp, "line_%04d = %d # padding to blow up an unclipped read\n", i, i); + } + fclose(fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "test-project"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, proj_dir); + + cbm_node_t mod = {0}; + mod.project = proj; + mod.label = "Module"; + mod.name = "big"; + mod.qualified_name = "test-project.big"; + mod.file_path = "big.py"; + mod.start_line = 1; + mod.end_line = BIG_LINES; + ASSERT_GT(cbm_store_upsert_node(st, &mod), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"get_code_snippet\",\"arguments\":{\"project\":\"test-project\"," + "\"qualified_name\":\"test-project.big\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "\"source_clipped\":true")); + /* The whole 2000-line file (~100KB) must NOT be in the response. */ + ASSERT_TRUE(strlen(inner) < 60000); + /* The last line must be absent (clipped), the first present. */ + ASSERT_NOT_NULL(strstr(inner, "line_0000")); + ASSERT_NULL(strstr(inner, "line_1999")); + free(inner); + free(resp); + cbm_mcp_server_free(srv); + th_rmtree(tmp); + PASS(); +} + TEST(tool_search_graph_includes_node_properties) { /* Node properties are OPT-IN columns in the default TOON output: the * default row is qn/label/file/lines/degrees only, `fields` adds the @@ -6809,6 +6936,8 @@ SUITE(mcp) { RUN_TEST(tool_get_graph_schema_empty); RUN_TEST(tool_unknown_tool); RUN_TEST(tool_search_graph_basic); + RUN_TEST(tool_get_architecture_cycles_detects_scc); + RUN_TEST(tool_get_code_snippet_clips_whole_file_node); RUN_TEST(tool_search_graph_includes_node_properties); RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields); RUN_TEST(tool_lean_defaults_schema_and_status); From 47e4d25dac2f87f32e943d2301f5e6d3ac2e9d45 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 00:40:54 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(mcp):=20review=20polish=20=E2=80=94=20h?= =?UTF-8?q?onest=20totals,=20complete=20counts,=20quoted=20cells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-eyes review of the phase-2/3 work; five defects, each against our own stated principles (no silent caps, exact counts, same model in both encodings): - trace callees_total/callers_total counted test-file rows even when include_tests=false hides them from the table — the scalar disagreed with the enumerable set (a field-eval agent read callers_total=175 against a handful of visible rows and stopped trusting the tool). Totals now apply the same filter as the emitters; description documents the transitive (within-depth) semantics. Guard test: filtered=1 vs include_tests=2. - cycles clamped the SCC list at 100 silently, losing the true count. cycles_total is now always exact and cycles_omitted is emitted when the display clamp engages (both encodings). - detect_changes accepted a 'limit' param (referenced by its own omitted- hint) that the schema never declared — undiscoverable. Declared. - detect_changes format:json lacked the impacted_modules rollup the tree encoding carries — model parity restored via a shared rollup helper. - tree search extra-field cells were appended raw: a signature with spaces shifted every following column. Cells now route through the shared emitters (space-triggered quoting, '-' placeholder); the properties test pins the quoted form. Full suite 6,373 passed / 0 failed; lint clean. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 182 +++++++++++++++++++++++++++++++++-------------- tests/test_mcp.c | 73 +++++++++++++++++++ 2 files changed, 202 insertions(+), 53 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index a945ee051..c77dc59bc 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -459,7 +459,9 @@ static const tool_def_t TOOLS[] = { "Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing. " "RESPONSE: prefix-grouped tree rows — callees/callers grouped under their shared " "qn-prefix, `name hop` per row (full qn = group prefix + dot + name); exact " - "callees_total/callers_total on every page; risk/args flags use a flat table. " + "callees_total/callers_total on every page = ALL nodes reachable within depth (transitive, " + "not just direct; test files excluded unless include_tests). risk/args flags use a flat " + "table. " "`truncated: true` + `next` = more rows — pass next back as cursor. " "format=\"json\" returns the SAME tree model as structured JSON.", "{\"type\":\"object\",\"properties\":{\"function_name\":{\"type\":\"string\"},\"project\":{" @@ -614,7 +616,10 @@ static const tool_def_t TOOLS[] = { "the " "changed symbols. outbound = what the changed code depends on. both = union.\"}," "\"depth\":{\"type\":\"integer\",\"default\":2,\"description\":\"Max traversal hops from the " - "changed symbols.\"},\"base_branch\":{\"type\":" + "changed symbols.\"},\"limit\":{\"type\":\"integer\",\"default\":200,\"maximum\":5000," + "\"description\":\"Per-symbol impacted rows shown (nearest hops first). impacted_total is " + "always exact and the impacted_modules rollup always complete regardless.\"}," + "\"base_branch\":{\"type\":" "\"string\",\"default\":\"main\"},\"since\":{\"type\":\"string\",\"description\":" "\"Git ref or tag to compare from (e.g. HEAD~5, v0.5.0). Diffs ...HEAD.\"}," "\"format\":{\"type\":\"string\",\"enum\":[\"tree\",\"json\"],\"default\":\"tree\"}}," @@ -2800,8 +2805,10 @@ static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int snprintf(row, sizeof(row), " %s %s %s %d %d", shortname, sr->node.label ? sr->node.label : "", lines, sr->in_degree, sr->out_degree); cbm_sb_append(sb, row); - /* Extra property columns (fields param), space-delimited; missing - * values emit as "-" so column positions stay stable. */ + /* Extra property columns (fields param). Routed through the shared + * cell emitters so values with spaces (signatures, docstrings) are + * QUOTED — a raw append would shift every following column. Missing + * values emit as "-" (the emitter's empty-cell placeholder). */ if (nfields > 0) { yyjson_doc *pd = (sr->node.properties_json && sr->node.properties_json[0]) @@ -2810,19 +2817,17 @@ static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int yyjson_val *pr = pd ? yyjson_doc_get_root(pd) : NULL; for (int f = 0; f < nfields; f++) { yyjson_val *v = (pr && yyjson_is_obj(pr)) ? yyjson_obj_get(pr, fields[f]) : NULL; - char cell[CBM_SZ_256]; if (v && yyjson_is_str(v)) { - snprintf(cell, sizeof(cell), " %s", yyjson_get_str(v)); + cbm_tree_cell_str(sb, yyjson_get_str(v), false); } else if (v && yyjson_is_int(v)) { - snprintf(cell, sizeof(cell), " %lld", (long long)yyjson_get_int(v)); + cbm_tree_cell_int(sb, yyjson_get_int(v), false); } else if (v && yyjson_is_real(v)) { - snprintf(cell, sizeof(cell), " %.3g", yyjson_get_real(v)); + cbm_tree_cell_real(sb, yyjson_get_real(v), false); } else if (v && yyjson_is_bool(v)) { - snprintf(cell, sizeof(cell), " %s", yyjson_get_bool(v) ? "true" : "false"); + cbm_tree_cell_bool(sb, yyjson_get_bool(v), false); } else { - snprintf(cell, sizeof(cell), " -"); + cbm_tree_cell_str(sb, "", false); /* emits "-" */ } - cbm_sb_append(sb, cell); } if (pd) { yyjson_doc_free(pd); @@ -4287,11 +4292,12 @@ static void arch_join_list(char *buf, size_t sz, const char **items, int n) { enum { ARCH_SCC_MAX_EDGES = 400000, ARCH_SCC_MAX_CYCLES = 100, ARCH_SCC_MEMBERS_SHOWN = 20 }; static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t ***out_members, - int **out_sizes, int *out_ncycles, int *scanned_edges, - bool *edges_truncated) { + int **out_sizes, int *out_ncycles, int *out_total_cycles, + int *scanned_edges, bool *edges_truncated) { *out_members = NULL; *out_sizes = NULL; *out_ncycles = 0; + *out_total_cycles = 0; *scanned_edges = 0; *edges_truncated = false; @@ -4340,6 +4346,7 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t ncyc++; } } + *out_total_cycles = ncyc; /* the TRUE count, before the display clamp */ if (ncyc > ARCH_SCC_MAX_CYCLES) { ncyc = ARCH_SCC_MAX_CYCLES; } @@ -4714,17 +4721,25 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { int64_t **members = NULL; int *sizes = NULL; int ncyc = 0; + int total_cyc = 0; int scanned = 0; bool etrunc = false; - if (arch_compute_cycles(store, project, &members, &sizes, &ncyc, &scanned, &etrunc) == - CBM_STORE_OK) { + if (arch_compute_cycles(store, project, &members, &sizes, &ncyc, &total_cyc, &scanned, + &etrunc) == CBM_STORE_OK) { cbm_tree_scalar_int(&sb, "call_edges_scanned", scanned); + cbm_tree_scalar_int(&sb, "cycles_total", total_cyc); if (etrunc) { cbm_tree_scalar_bool(&sb, "cycles_partial", true); cbm_tree_scalar_str(&sb, "cycles_hint", "call graph exceeded the scan budget; cycle list may be " "incomplete"); } + if (total_cyc > ncyc) { + char omit[CBM_SZ_128]; + snprintf(omit, sizeof(omit), "cycles_omitted: %d (showing the first %d)\n", + total_cyc - ncyc, ncyc); + cbm_sb_append(&sb, omit); + } char hdr[CBM_SZ_128]; snprintf(hdr, sizeof(hdr), "cycles: %d (rows: size members; circular CALLS dependencies)\n", ncyc); @@ -4998,11 +5013,13 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { int64_t **members = NULL; int *sizes = NULL; int ncyc = 0; + int total_cyc = 0; int scanned = 0; bool etrunc = false; - if (arch_compute_cycles(store, project, &members, &sizes, &ncyc, &scanned, &etrunc) == - CBM_STORE_OK) { + if (arch_compute_cycles(store, project, &members, &sizes, &ncyc, &total_cyc, &scanned, + &etrunc) == CBM_STORE_OK) { yyjson_mut_obj_add_int(doc, root, "call_edges_scanned", scanned); + yyjson_mut_obj_add_int(doc, root, "cycles_total", total_cyc); yyjson_mut_obj_add_bool(doc, root, "cycles_partial", etrunc); yyjson_mut_val *cyc = yyjson_mut_arr(doc); for (int c = 0; c < ncyc; c++) { @@ -5867,6 +5884,24 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { view_in.visited += in_start; view_in.visited_count = in_len; + /* Totals must count what the caller can actually enumerate: when + * include_tests=false the tables hide test-file rows, so raw + * visited_count overstated the reachable set (a field-eval agent read + * callers_total=175 against a handful of visible rows and distrusted + * the tool). Count with the same filter the emitters apply. */ + int out_total = 0; + for (int i = 0; i < tr_out.visited_count; i++) { + if (include_tests || !is_test_file(tr_out.visited[i].node.file_path)) { + out_total++; + } + } + int in_total = 0; + for (int i = 0; i < tr_in.visited_count; i++) { + if (include_tests || !is_test_file(tr_in.visited[i].node.file_path)) { + in_total++; + } + } + char *json = NULL; if (!trace_legacy_json) { cbm_sb_t sb; @@ -5880,7 +5915,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { * table (extra columns) in the same tree syntax. */ bool flat_trace = risk_labels || data_flow; if (do_outbound) { - cbm_tree_scalar_int(&sb, "callees_total", tr_out.visited_count); + cbm_tree_scalar_int(&sb, "callees_total", out_total); if (flat_trace) { bfs_to_toon_table(&sb, "callees", &view_out, risk_labels, include_tests, data_flow); } else { @@ -5888,7 +5923,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } } if (do_inbound) { - cbm_tree_scalar_int(&sb, "callers_total", tr_in.visited_count); + cbm_tree_scalar_int(&sb, "callers_total", in_total); if (flat_trace) { bfs_to_toon_table(&sb, "callers", &view_in, risk_labels, include_tests, data_flow); } else { @@ -5915,13 +5950,13 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_str(doc, root, "mode", mode); } if (do_outbound) { - yyjson_mut_obj_add_int(doc, root, "callees_total", tr_out.visited_count); + yyjson_mut_obj_add_int(doc, root, "callees_total", out_total); yyjson_mut_obj_add_val( doc, root, "callees", bfs_to_tree_json(doc, &view_out, risk_labels, include_tests, data_flow)); } if (do_inbound) { - yyjson_mut_obj_add_int(doc, root, "callers_total", tr_in.visited_count); + yyjson_mut_obj_add_int(doc, root, "callers_total", in_total); yyjson_mut_obj_add_val( doc, root, "callers", bfs_to_tree_json(doc, &view_in, risk_labels, include_tests, data_flow)); @@ -8551,6 +8586,39 @@ static void detect_module_of(const char *file, char *out, size_t outsz) { out[len] = '\0'; } +/* Aggregate the impact set into the 2-segment module rollup. Fills up to + * DETECT_MODCAP (module, count) pairs; symbols beyond the cap land in + * *overflow (surfaced as "(other)", never silently dropped). Shared by the + * tree and json emitters so both encodings carry the same model. */ +enum { DETECT_MODCAP = 256 }; + +static int detect_module_rollup(const cbm_traverse_result_t *impact, char mods[][CBM_SZ_128], + int *mcnt, int *overflow) { + int nmods = 0; + *overflow = 0; + for (int i = 0; i < impact->visited_count; i++) { + char m[CBM_SZ_128]; + detect_module_of(impact->visited[i].node.file_path, m, sizeof(m)); + int j = 0; + for (; j < nmods; j++) { + if (strcmp(mods[j], m) == 0) { + mcnt[j]++; + break; + } + } + if (j == nmods) { + if (nmods < DETECT_MODCAP) { + snprintf(mods[nmods], CBM_SZ_128, "%s", m); + mcnt[nmods] = 1; + nmods++; + } else { + (*overflow)++; + } + } + } + return nmods; +} + /* Emit the impacted set as a grouped tree leg: rows grouped under their shared * (qn-prefix, file), `name label hop` per row. At most `limit` rows are listed * (the visited array is hop-ordered, so the closest — highest-signal — impact @@ -8847,41 +8915,24 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { /* module rollup: a quotient view of the blast radius */ if (impact.visited_count > 0) { cbm_sb_append(&sb, "impacted_modules: (rows: module count)\n"); - enum { MODCAP = 256 }; - char mods[MODCAP][CBM_SZ_128]; - int mcnt[MODCAP]; - int nmods = 0; - int overflow = 0; /* symbols whose module didn't fit MODCAP */ - for (int i = 0; i < impact.visited_count; i++) { - char m[CBM_SZ_128]; - detect_module_of(impact.visited[i].node.file_path, m, sizeof(m)); - int j = 0; - for (; j < nmods; j++) { - if (strcmp(mods[j], m) == 0) { - mcnt[j]++; - break; - } + char (*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); + int *mcnt = malloc(DETECT_MODCAP * sizeof(int)); + if (mods && mcnt) { + int overflow = 0; + int nmods = detect_module_rollup(&impact, mods, mcnt, &overflow); + for (int j = 0; j < nmods; j++) { + char mrow[CBM_SZ_256]; + snprintf(mrow, sizeof(mrow), " %s %d\n", mods[j], mcnt[j]); + cbm_sb_append(&sb, mrow); } - if (j == nmods) { - if (nmods < MODCAP) { - snprintf(mods[nmods], sizeof(mods[nmods]), "%s", m); - mcnt[nmods] = 1; - nmods++; - } else { - overflow++; /* never silently dropped — surfaced below */ - } + if (overflow > 0) { + char orow[CBM_SZ_128]; + snprintf(orow, sizeof(orow), " (other) %d\n", overflow); + cbm_sb_append(&sb, orow); } } - for (int j = 0; j < nmods; j++) { - char mrow[CBM_SZ_256]; - snprintf(mrow, sizeof(mrow), " %s %d\n", mods[j], mcnt[j]); - cbm_sb_append(&sb, mrow); - } - if (overflow > 0) { - char orow[CBM_SZ_128]; - snprintf(orow, sizeof(orow), " (other) %d\n", overflow); - cbm_sb_append(&sb, orow); - } + free(mods); + free(mcnt); } if (truncated) { cbm_tree_scalar_bool(&sb, "truncated", true); @@ -8925,6 +8976,31 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_arr_add_val(imp, o); } yyjson_mut_obj_add_val(doc, root_obj, "impacted", imp); + /* Model parity with the tree encoding: the complete module rollup. */ + if (impact.visited_count > 0) { + char (*mods)[CBM_SZ_128] = malloc(DETECT_MODCAP * CBM_SZ_128); + int *mcnt = malloc(DETECT_MODCAP * sizeof(int)); + if (mods && mcnt) { + int overflow = 0; + int nmods = detect_module_rollup(&impact, mods, mcnt, &overflow); + yyjson_mut_val *rollup = yyjson_mut_arr(doc); + for (int j = 0; j < nmods; j++) { + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, o, "module", mods[j]); + yyjson_mut_obj_add_int(doc, o, "count", mcnt[j]); + yyjson_mut_arr_add_val(rollup, o); + } + if (overflow > 0) { + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, o, "module", "(other)"); + yyjson_mut_obj_add_int(doc, o, "count", overflow); + yyjson_mut_arr_add_val(rollup, o); + } + yyjson_mut_obj_add_val(doc, root_obj, "impacted_modules", rollup); + } + free(mods); + free(mcnt); + } yyjson_mut_obj_add_bool(doc, root_obj, "truncated", truncated); if (is_error) { char hint_buf[CBM_SZ_256]; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 27d65fe9f..82b5469eb 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1090,6 +1090,76 @@ static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz); static void cleanup_snippet_dir(const char *tmp_dir); static char *extract_text_content(const char *mcp_result); +/* callers_total/callees_total must count what the caller can enumerate: with + * include_tests=false (default) test-file rows are hidden from the table, so + * the totals must apply the same filter — a raw visited_count overstated the + * set (field-eval agent read callers_total=175 against 2 visible rows and + * distrusted the tool). */ +TEST(tool_trace_totals_respect_test_filter) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "totproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/tot"); + + cbm_node_t tgt = {.project = proj, + .label = "Function", + .name = "tgt", + .qualified_name = "totproj.a.tgt", + .file_path = "a.c", + .start_line = 1, + .end_line = 5}; + int64_t tid = cbm_store_upsert_node(st, &tgt); + ASSERT_GT(tid, 0); + cbm_node_t prod = {.project = proj, + .label = "Function", + .name = "prod_caller", + .qualified_name = "totproj.a.prod_caller", + .file_path = "a.c", + .start_line = 10, + .end_line = 15}; + int64_t pid = cbm_store_upsert_node(st, &prod); + ASSERT_GT(pid, 0); + cbm_node_t tst = {.project = proj, + .label = "Function", + .name = "test_caller", + .qualified_name = "totproj.t.test_caller", + .file_path = "tests/test_x.c", + .start_line = 1, + .end_line = 5}; + int64_t xid = cbm_store_upsert_node(st, &tst); + ASSERT_GT(xid, 0); + cbm_edge_t e1 = {.project = proj, .source_id = pid, .target_id = tid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e1), 0); + cbm_edge_t e2 = {.project = proj, .source_id = xid, .target_id = tid, .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &e2), 0); + + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\"," + "\"function_name\":\"tgt\",\"direction\":\"inbound\"}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callers_total: 1")); /* test row filtered */ + free(inner); + + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\",\"params\":{" + "\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\"," + "\"function_name\":\"tgt\",\"direction\":\"inbound\",\"include_tests\":true}}}"); + ASSERT_NOT_NULL(resp); + inner = extract_text_content(resp); + free(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "callers_total: 2")); /* both visible now */ + free(inner); + cbm_mcp_server_free(srv); + PASS(); +} + /* SCC condensation (get_architecture aspect "cycles"): a 3-function CALLS * cycle A->B->C->A must be reported as one circular dependency of size 3 with * all three members; a separate acyclic chain (D->E) must NOT appear. The @@ -1255,6 +1325,8 @@ TEST(tool_search_graph_includes_node_properties) { inner = extract_text_content(resp); ASSERT_NOT_NULL(inner); ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out signature;")); + /* values with spaces are QUOTED so column positions survive */ + ASSERT_NOT_NULL(strstr(inner, "\"func HandleRequest() error\"")); ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); free(inner); free(resp); @@ -6936,6 +7008,7 @@ SUITE(mcp) { RUN_TEST(tool_get_graph_schema_empty); RUN_TEST(tool_unknown_tool); RUN_TEST(tool_search_graph_basic); + RUN_TEST(tool_trace_totals_respect_test_filter); RUN_TEST(tool_get_architecture_cycles_detects_scc); RUN_TEST(tool_get_code_snippet_clips_whole_file_node); RUN_TEST(tool_search_graph_includes_node_properties); From 60390affb8685f31a4f2339a7e451e63b1495527 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 01:26:12 +0200 Subject: [PATCH 5/6] fix(mcp): close the legacy-generation cursor gap; json = the tree model everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh review pass over the whole branch; three classes of fix: Cursor safety — the 'legacy' generation gap: A pre-migration DB (indexed by an older binary, opened read-only) has no store_meta, so its generation reads 'legacy' — and 'legacy' == 'legacy' across rebuilds, which silently defeats stale-cursor detection for exactly those DBs: a stale (hop,id) watermark would resume on wrong node ids. Cursors are now neither minted (truncated + raise-limit hint instead) nor accepted (cursor_unsupported teaching error) under a legacy generation. The standard flow was verified safe empirically: every index run seeds store_meta via cbm_store_upsert_project in the flush path, and a rebuilt DB file gets a fresh random db_uid, so aliasing is impossible there. The guard is unreachable through the public API (upsert always seeds) — it protects only genuinely old DBs, hence no synthetic test. format:'json' now emits the tree model on EVERY tool (the last five): - BM25 query mode: {total, cols, rows[[qn,label,file,lines,rank]]} — rank order preserved, ranked output is never regrouped. - semantic: {semantic: {cols, rows[[qn,label,file,score]]}}. - search_code compact/full: {cols, rows} with nested match-line arrays; full appends a per-row source object (same guarded, windowed reader); raw_matches likewise {cols, rows}. files mode stays a plain list. - get_architecture: all twelve sections emit {cols, rows} via one shared section helper; cluster row cells carry their nested lists as arrays. - include_connected became a real column in BOTH encodings (';'-joined cell in the text tree, nested name array in json) — the include-connected-forces-JSON coupling is gone, and the last legacy per-node-object emitters (emit_search_results, enrich_node_properties) are deleted. Teaching-error parity: detect_changes now rejects an unknown with the valid values (it silently fell back to inbound; trace_path already errored — the caller would have misread the result's semantics). Full suite 6,373 passed / 0 failed; lint clean. Live-verified on the self-index: bm25/search_code/architecture json shapes, the connected column, and cursor pagination. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 629 ++++++++++++++++++++++++++------------------------ 1 file changed, 325 insertions(+), 304 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index c77dc59bc..a04057419 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -2160,8 +2160,10 @@ static void enrich_add_bfs(yyjson_mut_doc *doc, yyjson_mut_val *arr, cbm_travers } /* Enrich search result with 1-hop connected node names (inbound + outbound). */ -static void enrich_connected(yyjson_mut_doc *doc, yyjson_mut_val *item, cbm_store_t *store, - int64_t node_id, const char *relationship) { +/* Build the connected-names array (1-hop callers + callees) for a node. + * Returns a (possibly empty) yyjson array owned by doc. */ +static yyjson_mut_val *enrich_connected(yyjson_mut_doc *doc, cbm_store_t *store, int64_t node_id, + const char *relationship) { const char *et[] = {relationship ? relationship : "CALLS"}; yyjson_mut_val *conn = yyjson_mut_arr(doc); @@ -2176,8 +2178,33 @@ static void enrich_connected(yyjson_mut_doc *doc, yyjson_mut_val *item, cbm_stor enrich_add_bfs(doc, conn, &tr_out); cbm_store_traverse_free(&tr_out); - if (yyjson_mut_arr_size(conn) > 0) { - yyjson_mut_obj_add_val(doc, item, "connected_names", conn); + return conn; +} + +/* Text-tree variant: the same 1-hop neighbor names ';'-joined into buf (no + * commas/spaces in names, so the cell needs no quoting). Empty when none. */ +static void enrich_connected_joined(cbm_store_t *store, int64_t node_id, const char *relationship, + char *buf, size_t bufsz) { + buf[0] = '\0'; + const char *et[] = {relationship ? relationship : "CALLS"}; + size_t off = 0; + const char *dirs[2] = {"inbound", "outbound"}; + for (int d = 0; d < 2; d++) { + cbm_traverse_result_t tr = {0}; + cbm_store_bfs(store, node_id, dirs[d], et, SKIP_ONE, SKIP_ONE, MCP_DEFAULT_LIMIT, &tr); + for (int j = 0; j < tr.visited_count && off + 2 < bufsz; j++) { + if (!tr.visited[j].node.name) { + continue; + } + int n = + snprintf(buf + off, bufsz - off, "%s%s", off ? ";" : "", tr.visited[j].node.name); + if (n < 0 || (size_t)n >= bufsz - off) { + buf[off] = '\0'; + break; + } + off += (size_t)n; + } + cbm_store_traverse_free(&tr); } } @@ -2427,34 +2454,45 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu return cbm_sb_finish(&sb); } + /* format:"json" = json-stringified tree: cols + column-ordered row + * arrays (rank order preserved — no grouping on ranked output). */ 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_int(doc, root, "total", total); yyjson_mut_obj_add_str(doc, root, "search_mode", "bm25"); + yyjson_mut_val *jcols = yyjson_mut_arr(doc); + static const char *const bm25_cols[] = {"qn", "label", "file", "lines", "rank"}; + for (size_t ci = 0; ci < sizeof(bm25_cols) / sizeof(bm25_cols[0]); ci++) { + yyjson_mut_arr_add_str(doc, jcols, bm25_cols[ci]); + } + yyjson_mut_obj_add_val(doc, root, "cols", jcols); - yyjson_mut_val *results = yyjson_mut_arr(doc); + yyjson_mut_val *rows = yyjson_mut_arr(doc); int emitted = 0; while (sqlite3_step(stmt) == SQLITE_ROW) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, item, "name", - (const char *)sqlite3_column_text(stmt, BM25_COL_NAME)); - yyjson_mut_obj_add_strcpy(doc, item, "qualified_name", - (const char *)sqlite3_column_text(stmt, BM25_COL_QN)); - yyjson_mut_obj_add_strcpy(doc, item, "label", + char lines[CBM_SZ_32]; + int sl = sqlite3_column_int(stmt, BM25_COL_START); + int el = sqlite3_column_int(stmt, BM25_COL_END); + if (sl > 0) { + snprintf(lines, sizeof(lines), "%d-%d", sl, el > sl ? el : sl); + } else { + lines[0] = '\0'; + } + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, (const char *)sqlite3_column_text(stmt, BM25_COL_QN)); + yyjson_mut_arr_add_strcpy(doc, row, (const char *)sqlite3_column_text(stmt, BM25_COL_LABEL)); - yyjson_mut_obj_add_strcpy(doc, item, "file_path", - (const char *)sqlite3_column_text(stmt, BM25_COL_FILE)); - yyjson_mut_obj_add_int(doc, item, "start_line", sqlite3_column_int(stmt, BM25_COL_START)); - yyjson_mut_obj_add_int(doc, item, "end_line", sqlite3_column_int(stmt, BM25_COL_END)); - yyjson_mut_obj_add_real(doc, item, "rank", sqlite3_column_double(stmt, BM25_COL_RANK)); - yyjson_mut_arr_add_val(results, item); + yyjson_mut_arr_add_strcpy(doc, row, (const char *)sqlite3_column_text(stmt, BM25_COL_FILE)); + yyjson_mut_arr_add_strcpy(doc, row, lines); + yyjson_mut_arr_add_real(doc, row, sqlite3_column_double(stmt, BM25_COL_RANK)); + yyjson_mut_arr_add_val(rows, row); emitted++; } sqlite3_finalize(stmt); free(file_like); - yyjson_mut_obj_add_val(doc, root, "results", results); + yyjson_mut_obj_add_val(doc, root, "rows", rows); yyjson_mut_obj_add_bool(doc, root, "has_more", total > offset + emitted); char *json = yy_doc_to_str(doc); @@ -2462,52 +2500,6 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu return json; } -/* Forward declaration — defined later. enrich_node_properties parses the - * node's properties_json and grafts the parsed values onto the result item. - * It returns the parsed yyjson_doc which must outlive the serialization - * because yyjson_mut_obj_add_val uses zero-copy strings into that doc. */ -static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const char *properties_json); - -/* Emit the cbm_store_search results as a JSON "results" array on the doc. - * Property docs created via enrich_node_properties are collected in - * *out_pdocs (count in *out_pdoc_count) and must be freed by the caller - * AFTER serializing doc, since yyjson_mut strings are zero-copy pointers - * into those parsed docs. The caller also frees out_pdocs itself. */ -static void emit_search_results(yyjson_mut_doc *doc, yyjson_mut_val *root, - const cbm_search_output_t *out, cbm_store_t *store, - const char *relationship, bool include_connected, int offset, - yyjson_doc ***out_pdocs, int *out_pdoc_count) { - yyjson_doc **pdocs = out->count > 0 ? malloc((size_t)out->count * sizeof(yyjson_doc *)) : NULL; - int pdoc_count = 0; - yyjson_mut_obj_add_int(doc, root, "total", out->total); - yyjson_mut_val *results = yyjson_mut_arr(doc); - for (int i = 0; i < out->count; i++) { - cbm_search_result_t *sr = &out->results[i]; - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", sr->node.name ? sr->node.name : ""); - yyjson_mut_obj_add_str(doc, item, "qualified_name", - sr->node.qualified_name ? sr->node.qualified_name : ""); - yyjson_mut_obj_add_str(doc, item, "label", sr->node.label ? sr->node.label : ""); - yyjson_mut_obj_add_str(doc, item, "file_path", - sr->node.file_path ? sr->node.file_path : ""); - yyjson_mut_obj_add_int(doc, item, "in_degree", sr->in_degree); - yyjson_mut_obj_add_int(doc, item, "out_degree", sr->out_degree); - if (include_connected && sr->node.id > 0) { - enrich_connected(doc, item, store, sr->node.id, relationship); - } - yyjson_doc *pdoc = enrich_node_properties(doc, item, sr->node.properties_json); - if (pdoc && pdocs) { - pdocs[pdoc_count++] = pdoc; - } - yyjson_mut_arr_add_val(results, item); - } - yyjson_mut_obj_add_val(doc, root, "results", results); - yyjson_mut_obj_add_bool(doc, root, "has_more", out->total > offset + out->count); - *out_pdocs = pdocs; - *out_pdoc_count = pdoc_count; -} - /* Extract keyword strings from a yyjson array into `keywords`. Returns the * number of strings copied (capped at `max_out`). */ static int extract_semantic_keywords(yyjson_val *sq_val, const char **keywords, int max_out) { @@ -2527,20 +2519,28 @@ static int extract_semantic_keywords(yyjson_val *sq_val, const char **keywords, return ki; } -/* Emit cbm_vector_result_t entries as a "semantic_results" array on the doc. */ +/* Emit vector-search hits in the json-tree model: "semantic": {cols, rows} + * — score order preserved (ranked output is never regrouped). */ static void emit_semantic_results(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_vector_result_t *vresults, int vcount) { + yyjson_mut_val *sem = yyjson_mut_obj(doc); + yyjson_mut_val *scols = yyjson_mut_arr(doc); + static const char *const sem_cols[] = {"qn", "label", "file", "score"}; + for (size_t ci = 0; ci < sizeof(sem_cols) / sizeof(sem_cols[0]); ci++) { + yyjson_mut_arr_add_str(doc, scols, sem_cols[ci]); + } + yyjson_mut_obj_add_val(doc, sem, "cols", scols); yyjson_mut_val *sem_results = yyjson_mut_arr(doc); for (int v = 0; v < vcount; v++) { - yyjson_mut_val *vitem = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, vitem, "name", vresults[v].name); - yyjson_mut_obj_add_strcpy(doc, vitem, "qualified_name", vresults[v].qualified_name); - yyjson_mut_obj_add_strcpy(doc, vitem, "label", vresults[v].label); - yyjson_mut_obj_add_strcpy(doc, vitem, "file_path", vresults[v].file_path); - yyjson_mut_obj_add_real(doc, vitem, "score", vresults[v].score); + yyjson_mut_val *vitem = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, vitem, vresults[v].qualified_name); + yyjson_mut_arr_add_strcpy(doc, vitem, vresults[v].label); + yyjson_mut_arr_add_strcpy(doc, vitem, vresults[v].file_path); + yyjson_mut_arr_add_real(doc, vitem, vresults[v].score); yyjson_mut_arr_add_val(sem_results, vitem); } - yyjson_mut_obj_add_val(doc, root, "semantic_results", sem_results); + yyjson_mut_obj_add_val(doc, sem, "rows", sem_results); + yyjson_mut_obj_add_val(doc, root, "semantic", sem); } /* Run the semantic_query vector search from raw args. Sets *out_vresults / @@ -2600,10 +2600,10 @@ static bool run_semantic_query(yyjson_mut_doc *doc, yyjson_mut_val *root, const return type_error; } -/* ── TOON output for search_graph ─────────────────────────────────── - * Default response encoding: header+rows tables (compact_out.h). The - * verbose per-node JSON objects remain available via format:"json" and - * are forced for include_connected=true (nested neighbor lists). */ +/* ── Tree output for search_graph ─────────────────────────────────── + * Default response encoding: grouped tree rows (compact_out.h). The same + * model is available as structured JSON via format:"json"; include_connected + * adds a `connected` column in BOTH encodings. */ enum { SG_MAX_EXTRA_FIELDS = 12 }; @@ -2769,13 +2769,17 @@ static int sg_cmp_by_qn(const void *pa, const void *pb) { } static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int offset, - const char *const *fields, int nfields) { + const char *const *fields, int nfields, cbm_store_t *store, + const char *relationship, bool include_connected) { char buf[CBM_SZ_512]; char extra_cols[CBM_SZ_256] = ""; for (int f = 0; f < nfields; f++) { strncat(extra_cols, " ", sizeof(extra_cols) - strlen(extra_cols) - 1); strncat(extra_cols, fields[f], sizeof(extra_cols) - strlen(extra_cols) - 1); } + if (include_connected) { + strncat(extra_cols, " connected", sizeof(extra_cols) - strlen(extra_cols) - 1); + } snprintf(buf, sizeof(buf), "total: %d\nresults: %d (rows: name label lines in out%s; " "qn = group prefix + \".\" + name)\n", @@ -2833,6 +2837,11 @@ static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int yyjson_doc_free(pd); } } + if (include_connected && sr->node.id > 0) { + char joined[CBM_SZ_1K]; + enrich_connected_joined(store, sr->node.id, relationship, joined, sizeof(joined)); + cbm_tree_cell_str(sb, joined, false); /* empty emits "-" */ + } cbm_sb_append(sb, "\n"); } snprintf(buf, sizeof(buf), "has_more: %s\n", @@ -2846,7 +2855,9 @@ static void emit_search_results_tree(cbm_sb_t *sb, cbm_search_output_t *out, int * envelopes; that legacy shape was 84% key overhead). */ static void emit_search_results_tree_json(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_search_output_t *out, int offset, - const char *const *fields, int nfields) { + const char *const *fields, int nfields, + cbm_store_t *store, const char *relationship, + bool include_connected) { yyjson_mut_obj_add_int(doc, root, "total", out->total); yyjson_mut_obj_add_int(doc, root, "count", out->count); yyjson_mut_val *cols = yyjson_mut_arr(doc); @@ -2857,6 +2868,9 @@ static void emit_search_results_tree_json(yyjson_mut_doc *doc, yyjson_mut_val *r for (int f = 0; f < nfields; f++) { yyjson_mut_arr_add_strcpy(doc, cols, fields[f]); } + if (include_connected) { + yyjson_mut_arr_add_str(doc, cols, "connected"); + } yyjson_mut_obj_add_val(doc, root, "cols", cols); if (out->count > 1) { qsort(out->results, (size_t)out->count, sizeof(cbm_search_result_t), sg_cmp_by_qn); @@ -2915,6 +2929,9 @@ static void emit_search_results_tree_json(yyjson_mut_doc *doc, yyjson_mut_val *r yyjson_doc_free(pd); } } + if (include_connected && sr->node.id > 0) { + yyjson_mut_arr_add_val(row, enrich_connected(doc, store, sr->node.id, relationship)); + } yyjson_mut_arr_add_val(cur_rows, row); } yyjson_mut_obj_add_val(doc, root, "groups", groups); @@ -2947,17 +2964,11 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { return not_indexed; } - /* Response encoding: grouped tree rows by default. - * format:"json" restores the legacy verbose per-node objects; nested - * neighbor lists (include_connected) need them, so they force JSON. - * format:"tree" is the Phase-2 A/B candidate: rows grouped by - * (qn-prefix, file) with the shared prefix printed once. */ + /* Response encoding: grouped tree rows by default; format:"json" emits + * the SAME tree model as structured JSON (groups + row arrays). */ char *format_arg = cbm_mcp_get_string_arg(args, "format"); bool legacy_json = format_arg && strcmp(format_arg, "json") == 0; free(format_arg); - if (cbm_mcp_get_bool_arg(args, "include_connected")) { - legacy_json = true; - } /* BM25 path: if `query` is set, run FTS5 full-text search with ranking * and return early. The regex/vector path below is untouched for all @@ -3052,7 +3063,8 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { if (detail_ids) { emit_search_results_toon(&sb, &tout, offset, fields, nfields, detail_ids); } else { - emit_search_results_tree(&sb, &tout, offset, fields, nfields); + emit_search_results_tree(&sb, &tout, offset, fields, nfields, store, + relationship, include_connected); } if (core_fields_requested) { cbm_tree_scalar_str( @@ -3130,21 +3142,16 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_doc **props_docs = NULL; - int props_doc_count = 0; - if (include_connected) { - /* Nested neighbor lists keep the per-node object shape (the only - * remaining consumer of it; conversion tracked for the tree-json - * model's nested form). */ - emit_search_results(doc, root, &out, store, relationship, include_connected, offset, - &props_docs, &props_doc_count); - } else { - /* format:"json" = json-stringified tree: same grouped model as the - * default text output, structured for parsing. */ + /* format:"json" = json-stringified tree: same grouped model as the + * default text output, structured for parsing. include_connected adds a + * nested per-row `connected` array — the legacy per-node-object shape is + * gone. */ + { const char *jfields[SG_MAX_EXTRA_FIELDS]; yyjson_doc *jfields_owner = NULL; int jnfields = sg_parse_fields(args, jfields, SG_MAX_EXTRA_FIELDS, &jfields_owner, NULL); - emit_search_results_tree_json(doc, root, &out, offset, jfields, jnfields); + emit_search_results_tree_json(doc, root, &out, offset, jfields, jnfields, store, + relationship, include_connected); if (jfields_owner) { yyjson_doc_free(jfields_owner); } @@ -3171,10 +3178,6 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { bool sq_type_error = run_semantic_query(doc, root, args, store, project, limit); if (sq_type_error) { - for (int pi = 0; pi < props_doc_count; pi++) { - yyjson_doc_free(props_docs[pi]); - } - free(props_docs); yyjson_mut_doc_free(doc); cbm_store_search_free(&out); free(project); @@ -3192,12 +3195,6 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } char *json = yy_doc_to_str(doc); - /* Property docs are zero-copy referenced by the mut doc — they must - * outlive yy_doc_to_str. Free them once serialization is complete. */ - for (int pi = 0; pi < props_doc_count; pi++) { - yyjson_doc_free(props_docs[pi]); - } - free(props_docs); yyjson_mut_doc_free(doc); cbm_store_search_free(&out); @@ -4405,6 +4402,22 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t return CBM_STORE_OK; } +/* Begin a json-tree {cols, rows} section under `key` on root; returns the + * rows array for the caller to fill with column-ordered row arrays. */ +static yyjson_mut_val *arch_json_section(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *key, + const char *const *cols, int ncols) { + yyjson_mut_val *sec = yyjson_mut_obj(doc); + yyjson_mut_val *c = yyjson_mut_arr(doc); + for (int i = 0; i < ncols; i++) { + yyjson_mut_arr_add_str(doc, c, cols[i]); + } + yyjson_mut_obj_add_val(doc, sec, "cols", c); + yyjson_mut_val *rows = yyjson_mut_arr(doc); + yyjson_mut_obj_add_val(doc, sec, "rows", rows); + yyjson_mut_obj_add_val(doc, root, key, sec); + return rows; +} + /* Fetch the qualified_name for a node id, or a "#" fallback. */ static void arch_node_qn(cbm_store_t *store, int64_t id, char *out, size_t outsz) { cbm_node_t n = {0}; @@ -4519,7 +4532,7 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { bool path_scoped = cbm_store_normalize_arch_path(scope_path, norm_path, sizeof(norm_path)); /* Response encoding: tree tables by default; format:"json" emits the - * legacy per-item objects. */ + * same model as structured JSON ({cols, rows} per section). */ char *arch_format = cbm_mcp_get_string_arg(args, "format"); bool arch_legacy_json = arch_format && strcmp(arch_format, "json") == 0; free(arch_format); @@ -4804,31 +4817,31 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_int(doc, root, "total_nodes", node_count); yyjson_mut_obj_add_int(doc, root, "total_edges", edge_count); - /* Node label summary */ + /* Every section below is the json-tree model: {cols, rows[[...]]} — + * mirrors the text tree tables one-for-one. */ if (aspect_wanted(aspects_doc, aspects_arr, "structure")) { - yyjson_mut_val *labels = yyjson_mut_arr(doc); + static const char *const lcols[] = {"label", "count"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "node_labels", lcols, 2); for (int i = 0; i < schema.node_label_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "label", schema.node_labels[i].label); - yyjson_mut_obj_add_int(doc, item, "count", schema.node_labels[i].count); - yyjson_mut_arr_add_val(labels, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, schema.node_labels[i].label); + yyjson_mut_arr_add_int(doc, row, schema.node_labels[i].count); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "node_labels", labels); } - /* Edge type summary */ if (aspect_wanted(aspects_doc, aspects_arr, "dependencies")) { - yyjson_mut_val *types = yyjson_mut_arr(doc); + static const char *const tcols[] = {"type", "count"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "edge_types", tcols, 2); for (int i = 0; i < schema.edge_type_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "type", schema.edge_types[i].type); - yyjson_mut_obj_add_int(doc, item, "count", schema.edge_types[i].count); - yyjson_mut_arr_add_val(types, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, schema.edge_types[i].type); + yyjson_mut_arr_add_int(doc, row, schema.edge_types[i].count); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "edge_types", types); } - /* Relationship patterns */ + /* Relationship patterns (a plain list — no columns to factor) */ if (aspect_wanted(aspects_doc, aspects_arr, "routes") && schema.rel_pattern_count > 0) { yyjson_mut_val *pats = yyjson_mut_arr(doc); for (int i = 0; i < schema.rel_pattern_count; i++) { @@ -4837,173 +4850,151 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { yyjson_mut_obj_add_val(doc, root, "relationship_patterns", pats); } - /* Languages */ if (arch.language_count > 0) { - yyjson_mut_val *langs = yyjson_mut_arr(doc); + static const char *const gcols[] = {"language", "files"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "languages", gcols, 2); for (int i = 0; i < arch.language_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "language", - arch.languages[i].language ? arch.languages[i].language : ""); - yyjson_mut_obj_add_int(doc, item, "file_count", arch.languages[i].file_count); - yyjson_mut_arr_add_val(langs, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, + arch.languages[i].language ? arch.languages[i].language : ""); + yyjson_mut_arr_add_int(doc, row, arch.languages[i].file_count); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "languages", langs); } - /* Packages */ if (arch.package_count > 0) { - yyjson_mut_val *pkgs = yyjson_mut_arr(doc); + static const char *const kcols[] = {"name", "nodes", "fan_in", "fan_out"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "packages", kcols, 4); for (int i = 0; i < arch.package_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", - arch.packages[i].name ? arch.packages[i].name : ""); - yyjson_mut_obj_add_int(doc, item, "node_count", arch.packages[i].node_count); - yyjson_mut_obj_add_int(doc, item, "fan_in", arch.packages[i].fan_in); - yyjson_mut_obj_add_int(doc, item, "fan_out", arch.packages[i].fan_out); - yyjson_mut_arr_add_val(pkgs, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, arch.packages[i].name ? arch.packages[i].name : ""); + yyjson_mut_arr_add_int(doc, row, arch.packages[i].node_count); + yyjson_mut_arr_add_int(doc, row, arch.packages[i].fan_in); + yyjson_mut_arr_add_int(doc, row, arch.packages[i].fan_out); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "packages", pkgs); } - /* Entry points */ if (arch.entry_point_count > 0) { - yyjson_mut_val *eps = yyjson_mut_arr(doc); + static const char *const ecols[] = {"qn", "file"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "entry_points", ecols, 2); for (int i = 0; i < arch.entry_point_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", - arch.entry_points[i].name ? arch.entry_points[i].name : ""); - yyjson_mut_obj_add_str( - doc, item, "qualified_name", + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy( + doc, row, arch.entry_points[i].qualified_name ? arch.entry_points[i].qualified_name : ""); - yyjson_mut_obj_add_str(doc, item, "file", - arch.entry_points[i].file ? arch.entry_points[i].file : ""); - yyjson_mut_arr_add_val(eps, item); + yyjson_mut_arr_add_strcpy(doc, row, + arch.entry_points[i].file ? arch.entry_points[i].file : ""); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "entry_points", eps); } - /* HTTP routes */ if (arch.route_count > 0) { - yyjson_mut_val *routes = yyjson_mut_arr(doc); + static const char *const rcols[] = {"method", "path", "handler"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "routes", rcols, 3); for (int i = 0; i < arch.route_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "method", - arch.routes[i].method ? arch.routes[i].method : ""); - yyjson_mut_obj_add_str(doc, item, "path", - arch.routes[i].path ? arch.routes[i].path : ""); - yyjson_mut_obj_add_str(doc, item, "handler", - arch.routes[i].handler ? arch.routes[i].handler : ""); - yyjson_mut_arr_add_val(routes, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, arch.routes[i].method ? arch.routes[i].method : ""); + yyjson_mut_arr_add_strcpy(doc, row, arch.routes[i].path ? arch.routes[i].path : ""); + yyjson_mut_arr_add_strcpy(doc, row, + arch.routes[i].handler ? arch.routes[i].handler : ""); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "routes", routes); } - /* Hotspots */ if (arch.hotspot_count > 0) { - yyjson_mut_val *hotspots = yyjson_mut_arr(doc); + static const char *const hcols[] = {"qn", "fan_in"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "hotspots", hcols, 2); for (int i = 0; i < arch.hotspot_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", - arch.hotspots[i].name ? arch.hotspots[i].name : ""); - yyjson_mut_obj_add_str(doc, item, "qualified_name", - arch.hotspots[i].qualified_name ? arch.hotspots[i].qualified_name - : ""); - yyjson_mut_obj_add_int(doc, item, "fan_in", arch.hotspots[i].fan_in); - yyjson_mut_arr_add_val(hotspots, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy( + doc, row, arch.hotspots[i].qualified_name ? arch.hotspots[i].qualified_name : ""); + yyjson_mut_arr_add_int(doc, row, arch.hotspots[i].fan_in); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "hotspots", hotspots); } - /* Cross-package boundaries */ if (arch.boundary_count > 0) { - yyjson_mut_val *boundaries = yyjson_mut_arr(doc); + static const char *const bcols[] = {"from", "to", "calls"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "boundaries", bcols, 3); for (int i = 0; i < arch.boundary_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "from", - arch.boundaries[i].from ? arch.boundaries[i].from : ""); - yyjson_mut_obj_add_str(doc, item, "to", - arch.boundaries[i].to ? arch.boundaries[i].to : ""); - yyjson_mut_obj_add_int(doc, item, "call_count", arch.boundaries[i].call_count); - yyjson_mut_arr_add_val(boundaries, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, + arch.boundaries[i].from ? arch.boundaries[i].from : ""); + yyjson_mut_arr_add_strcpy(doc, row, arch.boundaries[i].to ? arch.boundaries[i].to : ""); + yyjson_mut_arr_add_int(doc, row, arch.boundaries[i].call_count); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "boundaries", boundaries); } - /* Cross-service links (HTTP/async between services) */ if (arch.service_count > 0) { - yyjson_mut_val *services = yyjson_mut_arr(doc); + static const char *const scols[] = {"from", "to", "type", "count"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "services", scols, 4); for (int i = 0; i < arch.service_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "from", - arch.services[i].from ? arch.services[i].from : ""); - yyjson_mut_obj_add_str(doc, item, "to", arch.services[i].to ? arch.services[i].to : ""); - yyjson_mut_obj_add_str(doc, item, "type", - arch.services[i].type ? arch.services[i].type : ""); - yyjson_mut_obj_add_int(doc, item, "count", arch.services[i].count); - yyjson_mut_arr_add_val(services, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, arch.services[i].from ? arch.services[i].from : ""); + yyjson_mut_arr_add_strcpy(doc, row, arch.services[i].to ? arch.services[i].to : ""); + yyjson_mut_arr_add_strcpy(doc, row, arch.services[i].type ? arch.services[i].type : ""); + yyjson_mut_arr_add_int(doc, row, arch.services[i].count); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "services", services); } - /* Package layers */ if (arch.layer_count > 0) { - yyjson_mut_val *layers = yyjson_mut_arr(doc); + static const char *const ycols[] = {"name", "layer", "reason"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "layers", ycols, 3); for (int i = 0; i < arch.layer_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", - arch.layers[i].name ? arch.layers[i].name : ""); - yyjson_mut_obj_add_str(doc, item, "layer", - arch.layers[i].layer ? arch.layers[i].layer : ""); - yyjson_mut_obj_add_str(doc, item, "reason", - arch.layers[i].reason ? arch.layers[i].reason : ""); - yyjson_mut_arr_add_val(layers, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, arch.layers[i].name ? arch.layers[i].name : ""); + yyjson_mut_arr_add_strcpy(doc, row, arch.layers[i].layer ? arch.layers[i].layer : ""); + yyjson_mut_arr_add_strcpy(doc, row, arch.layers[i].reason ? arch.layers[i].reason : ""); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "layers", layers); } - /* Clusters (community detection) */ if (arch.cluster_count > 0) { - yyjson_mut_val *clusters = yyjson_mut_arr(doc); + /* Nested lists stay nested arrays within the row (tree-json). */ + static const char *const ccols[] = {"id", "label", "members", "cohesion", + "top_nodes", "packages", "edge_types"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "clusters", ccols, 7); for (int i = 0; i < arch.cluster_count; i++) { const cbm_cluster_info_t *c = &arch.clusters[i]; - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_int(doc, item, "id", c->id); - yyjson_mut_obj_add_str(doc, item, "label", c->label ? c->label : ""); - yyjson_mut_obj_add_int(doc, item, "members", c->members); - yyjson_mut_obj_add_real(doc, item, "cohesion", c->cohesion); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_int(doc, row, c->id); + yyjson_mut_arr_add_strcpy(doc, row, c->label ? c->label : ""); + yyjson_mut_arr_add_int(doc, row, c->members); + yyjson_mut_arr_add_real(doc, row, c->cohesion); yyjson_mut_val *top = yyjson_mut_arr(doc); for (int j = 0; j < c->top_node_count; j++) { yyjson_mut_arr_add_str(doc, top, c->top_nodes[j] ? c->top_nodes[j] : ""); } - yyjson_mut_obj_add_val(doc, item, "top_nodes", top); + yyjson_mut_arr_add_val(row, top); yyjson_mut_val *pkgs = yyjson_mut_arr(doc); for (int j = 0; j < c->package_count; j++) { yyjson_mut_arr_add_str(doc, pkgs, c->packages[j] ? c->packages[j] : ""); } - yyjson_mut_obj_add_val(doc, item, "packages", pkgs); + yyjson_mut_arr_add_val(row, pkgs); yyjson_mut_val *etypes = yyjson_mut_arr(doc); for (int j = 0; j < c->edge_type_count; j++) { yyjson_mut_arr_add_str(doc, etypes, c->edge_types[j] ? c->edge_types[j] : ""); } - yyjson_mut_obj_add_val(doc, item, "edge_types", etypes); - yyjson_mut_arr_add_val(clusters, item); + yyjson_mut_arr_add_val(row, etypes); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "clusters", clusters); } - /* File tree */ if (arch.file_tree_count > 0) { - yyjson_mut_val *file_tree = yyjson_mut_arr(doc); + static const char *const fcols[] = {"path", "type", "children"}; + yyjson_mut_val *rows = arch_json_section(doc, root, "file_tree", fcols, 3); for (int i = 0; i < arch.file_tree_count; i++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "path", - arch.file_tree[i].path ? arch.file_tree[i].path : ""); - yyjson_mut_obj_add_str(doc, item, "type", - arch.file_tree[i].type ? arch.file_tree[i].type : ""); - yyjson_mut_obj_add_int(doc, item, "children", arch.file_tree[i].children); - yyjson_mut_arr_add_val(file_tree, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, + arch.file_tree[i].path ? arch.file_tree[i].path : ""); + yyjson_mut_arr_add_strcpy(doc, row, + arch.file_tree[i].type ? arch.file_tree[i].type : ""); + yyjson_mut_arr_add_int(doc, row, arch.file_tree[i].children); + yyjson_mut_arr_add_val(rows, row); } - yyjson_mut_obj_add_val(doc, root, "file_tree", file_tree); } append_cross_repo_summary(doc, root, &schema); @@ -5690,6 +5681,24 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { char *cursor_arg = cbm_mcp_get_string_arg(args, "cursor"); trace_cursor_t cur = {0}; bool have_cursor = false; + /* A "legacy" generation (pre-migration DB, e.g. opened read-only before + * any reindex) offers NO staleness detection: "legacy" == "legacy" across + * rebuilds, so a stale watermark would silently resume on wrong node ids. + * Cursors are therefore never minted nor accepted under it. */ + bool gen_legacy = strcmp(generation, "legacy") == 0; + if (gen_legacy && cursor_arg && cursor_arg[0]) { + free(cursor_arg); + free(func_name); + free(project); + free(direction); + free(mode); + free(param_name); + return cbm_mcp_text_result( + "cursor_unsupported: this project's index predates generation tracking, so cursor " + "staleness cannot be detected. Re-call with a higher 'limit' instead, or re-index " + "the project to enable pagination.", + true); + } if (cursor_arg && cursor_arg[0]) { uint64_t qh = trace_params_hash(project, func_name, direction ? direction : "both", mode, @@ -5786,8 +5795,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } /* Response encoding: tree tables by default; format:"json" emits the - * legacy verbose per-hop objects; format:"tree" = Phase-2 A/B candidate - * (qn-prefix-grouped rows). */ + * same grouped model as structured JSON. */ char *trace_format = cbm_mcp_get_string_arg(args, "format"); bool trace_legacy_json = trace_format && strcmp(trace_format, "json") == 0; free(trace_format); @@ -5856,8 +5864,11 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } bool out_more = do_outbound && out_start + out_len < tr_out.visited_count; bool in_more = do_inbound && in_start + in_len < tr_in.visited_count; + bool more_rows = out_more || in_more; char next_tok[192] = ""; - if (out_more || in_more) { + /* Never mint a cursor under a "legacy" generation (no staleness + * detection); the truncated flag + raise-limit hint still fire below. */ + if (more_rows && !gen_legacy) { trace_cursor_t nc = {0}; snprintf(nc.generation, sizeof(nc.generation), "%s", generation); nc.qhash = trace_params_hash(project, func_name, direction, mode, depth, include_tests, @@ -5930,13 +5941,19 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { bfs_to_tree_table(&sb, "callers", &view_in, include_tests); } } - if (next_tok[0]) { + if (more_rows) { cbm_tree_scalar_bool(&sb, "truncated", true); - cbm_tree_scalar_str(&sb, "next", next_tok); - cbm_tree_scalar_str(&sb, "hint", - "more rows exist — re-call with cursor set to 'next' and ALL " - "other arguments identical (no duplicates), or narrow with " - "depth/edge_types"); + if (next_tok[0]) { + cbm_tree_scalar_str(&sb, "next", next_tok); + cbm_tree_scalar_str(&sb, "hint", + "more rows exist — re-call with cursor set to 'next' and ALL " + "other arguments identical (no duplicates), or narrow with " + "depth/edge_types"); + } else { + cbm_tree_scalar_str(&sb, "hint", + "more rows exist — re-call with a higher 'limit' (cursors " + "unavailable: index predates generation tracking)"); + } } json = cbm_sb_finish(&sb); } else { @@ -5961,9 +5978,11 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { doc, root, "callers", bfs_to_tree_json(doc, &view_in, risk_labels, include_tests, data_flow)); } - if (next_tok[0]) { + if (more_rows) { yyjson_mut_obj_add_bool(doc, root, "truncated", true); - yyjson_mut_obj_add_strcpy(doc, root, "next_cursor", next_tok); + if (next_tok[0]) { + yyjson_mut_obj_add_strcpy(doc, root, "next_cursor", next_tok); + } } /* Serialize BEFORE freeing traversal results (yyjson borrows strings) */ json = yy_doc_to_str(doc); @@ -7036,44 +7055,6 @@ static char *snippet_suggestions(const char *input, cbm_node_t *nodes, int count return result; } -/* Enrich a mutable JSON object with key-value pairs from a node's properties_json. - * Returns the parsed yyjson_doc (caller frees AFTER serialization — zero-copy). */ -static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *obj, - const char *properties_json) { - if (!properties_json || properties_json[0] == '\0') { - return NULL; - } - yyjson_doc *props_doc = yyjson_read(properties_json, strlen(properties_json), 0); - if (!props_doc) { - return NULL; - } - yyjson_val *props_root = yyjson_doc_get_root(props_doc); - if (!props_root || !yyjson_is_obj(props_root)) { - yyjson_doc_free(props_doc); - return NULL; - } - yyjson_obj_iter iter; - yyjson_obj_iter_init(props_root, &iter); - yyjson_val *key; - while ((key = yyjson_obj_iter_next(&iter))) { - yyjson_val *val = yyjson_obj_iter_get_val(key); - const char *k = yyjson_get_str(key); - if (!k) { - continue; - } - if (yyjson_is_str(val)) { - yyjson_mut_obj_add_str(doc, obj, k, yyjson_get_str(val)); - } else if (yyjson_is_bool(val)) { - yyjson_mut_obj_add_bool(doc, obj, k, yyjson_get_bool(val)); - } else if (yyjson_is_int(val)) { - yyjson_mut_obj_add_int(doc, obj, k, yyjson_get_int(val)); - } else if (yyjson_is_real(val)) { - yyjson_mut_obj_add_real(doc, obj, k, yyjson_get_real(val)); - } - } - return props_doc; /* caller frees after serialization */ -} - /* Resolve an absolute path from root_path + file_path, verify containment, * and read source lines. Sets *out_abs_path (caller frees). Returns source * string (caller frees) or NULL if path is invalid/unreadable. */ @@ -7863,41 +7844,69 @@ static char *assemble_search_output(search_result_t *sr, int sr_count, grep_matc yyjson_mut_obj_add_val(doc, root_obj, "files", build_dedup_files_array(doc, sr, output_count, raw, raw_count)); } else { + /* json-stringified tree: cols + column-ordered row arrays. FULL mode + * appends a per-row object cell with the (guarded, windowed) source — + * attach_result_source semantics unchanged. */ + yyjson_mut_val *jcols = yyjson_mut_arr(doc); + static const char *const sc_cols[] = {"qn", "label", "file", "lines", + "matches", "in", "out"}; + for (size_t ci = 0; ci < sizeof(sc_cols) / sizeof(sc_cols[0]); ci++) { + yyjson_mut_arr_add_str(doc, jcols, sc_cols[ci]); + } + if (mode == MODE_FULL) { + yyjson_mut_arr_add_str(doc, jcols, "source"); + } + yyjson_mut_obj_add_val(doc, root_obj, "cols", jcols); + yyjson_mut_val *results_arr = yyjson_mut_arr(doc); for (int ri = 0; ri < output_count; ri++) { search_result_t *r = &sr[ri]; - yyjson_mut_val *item = yyjson_mut_obj(doc); - - yyjson_mut_obj_add_str(doc, item, "node", r->node_name); - yyjson_mut_obj_add_str(doc, item, "qualified_name", r->qualified_name); - yyjson_mut_obj_add_str(doc, item, "label", r->label); - yyjson_mut_obj_add_str(doc, item, "file", r->file); - yyjson_mut_obj_add_int(doc, item, "start_line", r->start_line); - yyjson_mut_obj_add_int(doc, item, "end_line", r->end_line); - yyjson_mut_obj_add_int(doc, item, "in_degree", r->in_degree); - yyjson_mut_obj_add_int(doc, item, "out_degree", r->out_degree); - + char lines[CBM_SZ_32]; + if (r->start_line > 0) { + snprintf(lines, sizeof(lines), "%d-%d", r->start_line, + r->end_line > r->start_line ? r->end_line : r->start_line); + } else { + lines[0] = '\0'; + } + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_strcpy(doc, row, r->qualified_name); + yyjson_mut_arr_add_strcpy(doc, row, r->label); + yyjson_mut_arr_add_strcpy(doc, row, r->file); + yyjson_mut_arr_add_strcpy(doc, row, lines); yyjson_mut_val *ml = yyjson_mut_arr(doc); for (int j = 0; j < r->match_count; j++) { yyjson_mut_arr_add_int(doc, ml, r->match_lines[j]); } - yyjson_mut_obj_add_val(doc, item, "match_lines", ml); - attach_result_source(doc, item, r, mode, context_lines, root_path); - yyjson_mut_arr_add_val(results_arr, item); + yyjson_mut_arr_add_val(row, ml); + yyjson_mut_arr_add_int(doc, row, r->in_degree); + yyjson_mut_arr_add_int(doc, row, r->out_degree); + if (mode == MODE_FULL) { + yyjson_mut_val *src = yyjson_mut_obj(doc); + attach_result_source(doc, src, r, mode, context_lines, root_path); + yyjson_mut_arr_add_val(row, src); + } + yyjson_mut_arr_add_val(results_arr, row); } - yyjson_mut_obj_add_val(doc, root_obj, "results", results_arr); + yyjson_mut_obj_add_val(doc, root_obj, "rows", results_arr); enum { MAX_RAW = 20 }; + yyjson_mut_val *raw_obj = yyjson_mut_obj(doc); + yyjson_mut_val *rcols = yyjson_mut_arr(doc); + yyjson_mut_arr_add_str(doc, rcols, "file"); + yyjson_mut_arr_add_str(doc, rcols, "line"); + yyjson_mut_arr_add_str(doc, rcols, "content"); + yyjson_mut_obj_add_val(doc, raw_obj, "cols", rcols); yyjson_mut_val *raw_arr = yyjson_mut_arr(doc); int raw_output = raw_count < MAX_RAW ? raw_count : MAX_RAW; for (int ri = 0; ri < raw_output; ri++) { - yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "file", raw[ri].file); - yyjson_mut_obj_add_int(doc, item, "line", raw[ri].line); - yyjson_mut_obj_add_str(doc, item, "content", raw[ri].content); - yyjson_mut_arr_add_val(raw_arr, item); + yyjson_mut_val *row = yyjson_mut_arr(doc); + yyjson_mut_arr_add_str(doc, row, raw[ri].file); + yyjson_mut_arr_add_int(doc, row, raw[ri].line); + yyjson_mut_arr_add_str(doc, row, raw[ri].content); + yyjson_mut_arr_add_val(raw_arr, row); } - yyjson_mut_obj_add_val(doc, root_obj, "raw_matches", raw_arr); + yyjson_mut_obj_add_val(doc, raw_obj, "rows", raw_arr); + yyjson_mut_obj_add_val(doc, root_obj, "raw_matches", raw_obj); } yyjson_mut_obj_add_val(doc, root_obj, "directories", build_dir_distribution(doc, sr, sr_count)); @@ -8506,9 +8515,9 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { /* ── Phase 4: Context assembly (extracted helper) ─────────── */ - /* compact mode (default) emits tree tables; format:"json" keeps the - * legacy per-hit objects. full/files keep their JSON shapes (full is - * source-block-heavy; files is already minimal). */ + /* compact mode (default) emits tree tables; format:"json" emits the + * same model as structured JSON ({cols, rows}; full adds a per-row + * source cell; files is a plain list). */ char *sc_format = cbm_mcp_get_string_arg(args, "format"); bool sc_legacy_json = sc_format && strcmp(sc_format, "json") == 0; free(sc_format); @@ -8775,10 +8784,22 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { if (!direction) { direction = heap_strdup("inbound"); } + /* Teaching error, same contract as trace_path: never silently correct an + * unknown direction — the caller would misread the result's semantics. */ if (strcmp(direction, "inbound") != 0 && strcmp(direction, "outbound") != 0 && strcmp(direction, "both") != 0) { + char errbuf[CBM_SZ_256]; + snprintf(errbuf, sizeof(errbuf), + "invalid direction \"%s\" — use \"inbound\" (blast radius: transitive callers), " + "\"outbound\" (dependencies), or \"both\"", + direction); free(direction); - direction = heap_strdup("inbound"); + free(root_path); + free(project); + free(base_branch); + free(scope); + (void)cbm_pclose(fp); + return cbm_mcp_text_result(errbuf, true); } char *fmt = cbm_mcp_get_string_arg(args, "format"); bool legacy_json = fmt && strcmp(fmt, "json") == 0; From cc5f71bdff7565d521d8c74f748f4b92485c87c5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 10:36:08 +0200 Subject: [PATCH 6/6] fix(hook,smoke): teach the last two consumers the tree output shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR CI caught two consumers the format migration missed — both parsed tool output by its old shape and failed silently or with a wrong count: - hook-augment built its PreToolUse additionalContext by reading the legacy 'results' object-array from search_graph format:json; after the json-tree reshape that key no longer exists, so the hook emitted nothing (caught end-to-end by tests/windows/test_hook_augment.py). The parser now walks groups -> qn_prefix/file + column-ordered rows. A new local test feeds the parser the LIVE server envelope, so any future drift between response shape and hook parser fails in the normal suite on every platform — not only in the Windows CI guard. Reproduced RED (ctx NULL, the exact CI failure) before the fix, GREEN after. - smoke-test.sh carried six old-shape parsers (callers[N], rows[N]{, clusters[N], semantic[N]{, and the assert_toon_table helper); the tree headers never matched, so trace verification read 0 callers. All six updated to the tree contract; the full smoke script passes end-to-end locally. cli suite 222 passed; lint clean; scripted smoke ALL PASSED. Signed-off-by: Martin Vogel --- scripts/smoke-test.sh | 15 +++++---- src/cli/cli.h | 2 ++ src/cli/hook_augment.c | 76 ++++++++++++++++++++++++++++++------------ tests/test_cli.c | 44 ++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 28 deletions(-) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ec4e4d581..da1f535af 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -195,8 +195,9 @@ echo "OK: search_graph found $TOTAL result(s) for 'compute'" if ! TRACE=$(cli trace_path --project "$PROJECT" --function-name compute --direction inbound --depth 1); then echo "FAIL: trace_path (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1 fi -# trace_path default output is TOON: the callers[N]{...} header carries the count -CALLERS=$(echo "$TRACE" | sed -n 's/^callers\[\([0-9]*\)\].*/\1/p' | head -1) +# trace_path default output is the tree format: callers_total carries the +# exact reachable count (test files excluded by default) +CALLERS=$(echo "$TRACE" | sed -n 's/^callers_total: \([0-9]*\).*/\1/p' | head -1) CALLERS=${CALLERS:-0} if [ "$CALLERS" -lt 1 ]; then echo "FAIL: trace_path found 0 callers for 'compute'" @@ -278,7 +279,7 @@ cyp_first_cell() { # argv token, so string-literal args (e.g. replace(f.name,"a","A")) and Cypher # metacharacters {}|=~<>" need no JSON escaping. cli query_graph --project "$PROJECT" --query "$1" | - sed -n '/^rows\[/{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g' + sed -n '/^rows: /{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g' } # labels(n) → JSON list like ["Function"] @@ -366,7 +367,7 @@ LEFTV=$(cyp_first_cell 'MATCH (f:Function) RETURN left(f.name, 3) AS l LIMIT 1') # NOT EXISTS dead-code query (functions with no caller) CYPHER_NX=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WHERE NOT EXISTS { (f)<-[:CALLS]-() } RETURN f.name") -NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows\[[0-9]+\]\{' && echo "True" || echo "False") +NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows: [0-9]+' && echo "True" || echo "False") [ "$NX_OK" = "True" ] && echo "OK: query_graph NOT EXISTS dead-code query executed" || { echo "FAIL: NOT EXISTS query"; echo "$CYPHER_NX" | head -c 300; exit 1; } # CASE expression in RETURN @@ -390,7 +391,7 @@ if ! ARCH=$(cli get_architecture --project "$PROJECT" --aspects clusters); then echo "FAIL: get_architecture (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1 fi # get_architecture default output is TOON: clusters[N]{...} header carries the count -NCLUST=$(echo "$ARCH" | sed -n 's/^clusters\[\([0-9]*\)\].*/\1/p' | head -1) +NCLUST=$(echo "$ARCH" | sed -n 's/^clusters: \([0-9]*\).*/\1/p' | head -1) NCLUST=${NCLUST:-0} if [ "$NCLUST" -lt 1 ]; then echo "FAIL: get_architecture returned 0 community clusters"; echo "$ARCH" | head -c 400; exit 1 @@ -431,7 +432,7 @@ echo "=== Phase 3h: CLI input-mode guards (flags / stdin / --args-file / --help assert_json_obj() { python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if isinstance(d,dict) else 1)" 2>/dev/null; } # search_graph emits TOON by default: a results/semantic table header proves # the tool parsed its typed flags and produced a well-formed response. -assert_toon_table() { grep -qE '^(results|semantic)\[[0-9]+\]\{'; } +assert_toon_table() { grep -qE '^(results|semantic): [0-9]+'; } # B1: INTEGER flag — --limit is schema-typed integer; must parse and answer. if ! IM_INT=$(cli search_graph --project "$PROJECT" --name-pattern compute --limit 5); then @@ -458,7 +459,7 @@ fi if ! IM_ARR=$(cli search_graph --project "$PROJECT" --semantic-query send --semantic-query publish); then echo "FAIL B3: search_graph repeated --semantic-query exited non-zero"; cat "$CLI_STDERR"; exit 1 fi -if echo "$IM_ARR" | grep -qE '^semantic\[[0-9]+\]\{'; then +if echo "$IM_ARR" | grep -qE '^semantic: [0-9]+'; then echo "OK B3: ARRAY flag (repeated --semantic-query) → semantic TOON table" else echo "FAIL B3: repeated --semantic-query did not produce a semantic table"; echo "$IM_ARR" | head -c 300; exit 1 diff --git a/src/cli/cli.h b/src/cli/cli.h index de1ac04a2..f45325313 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -190,6 +190,8 @@ int cbm_resolve_claude_hook_command_for_testing(const char *script_name, bool wi char *command, size_t command_size); bool cbm_optional_hook_supported_for_testing(const char *agent_name, bool windows); void cbm_hook_sanitize_metadata_for_testing(const char *input, char *output, size_t output_size); +char *cbm_hook_augment_format_context_for_testing(const char *envelope, const char *token, + bool *is_error); int cbm_upsert_qwen_lifecycle_hooks_for_testing(const char *settings_path, const char *binary_path, bool windows); int cbm_upsert_qoder_context_hooks_for_testing(const char *settings_path, const char *binary_path); diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 029c490e8..7b172b1fe 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -368,9 +368,23 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is yyjson_doc_free(edoc); return NULL; } + /* json-tree shape: {total, cols, groups:[{qn_prefix, file, + * rows:[[name,label,lines,in,out],...]}]}. The full qualified name is + * qn_prefix + "." + row[0]; label is row[1]; file lives on the group. */ yyjson_val *iroot = yyjson_doc_get_root(idoc); - yyjson_val *results = yyjson_obj_get(iroot, "results"); - size_t nres = (results && yyjson_is_arr(results)) ? yyjson_arr_size(results) : 0; + yyjson_val *groups = yyjson_obj_get(iroot, "groups"); + size_t nres = 0; + size_t gidx; + size_t gmax; + yyjson_val *g; + if (groups && yyjson_is_arr(groups)) { + yyjson_arr_foreach(groups, gidx, gmax, g) { + yyjson_val *rows = yyjson_obj_get(g, "rows"); + if (rows && yyjson_is_arr(rows)) { + nres += yyjson_arr_size(rows); + } + } + } if (nres == 0) { yyjson_doc_free(idoc); yyjson_doc_free(edoc); @@ -389,26 +403,37 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is "(structured context; your search results below are " "unaffected):", nres, token); - size_t idx; - size_t maxn; - yyjson_val *r; - yyjson_arr_foreach(results, idx, maxn, r) { - if (off < 0 || off >= 3900) { - break; + yyjson_arr_foreach(groups, gidx, gmax, g) { + const char *prefix = ha_obj_str(g, "qn_prefix"); + const char *fp = ha_obj_str(g, "file"); + yyjson_val *rows = yyjson_obj_get(g, "rows"); + if (!rows || !yyjson_is_arr(rows)) { + continue; + } + size_t ridx; + size_t rmax; + yyjson_val *row; + yyjson_arr_foreach(rows, ridx, rmax, row) { + if (off < 0 || off >= 3900) { + break; + } + const char *nm = yyjson_get_str(yyjson_arr_get(row, 0)); + const char *lb = yyjson_get_str(yyjson_arr_get(row, 1)); + char disp[HA_METADATA_CAP]; + if (prefix && prefix[0] && nm && nm[0]) { + snprintf(disp, sizeof(disp), "%s.%s", prefix, nm); + } else { + snprintf(disp, sizeof(disp), "%s", nm ? nm : ""); + } + char safe_disp[HA_METADATA_CAP]; + char safe_path[HA_METADATA_CAP]; + char safe_label[HA_METADATA_CAP]; + ha_sanitize_metadata(disp, safe_disp, sizeof(safe_disp)); + ha_sanitize_metadata(fp, safe_path, sizeof(safe_path)); + ha_sanitize_metadata(lb, safe_label, sizeof(safe_label)); + off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", safe_disp, + safe_path, safe_label[0] ? " " : "", safe_label); } - const char *qn = ha_obj_str(r, "qualified_name"); - const char *nm = ha_obj_str(r, "name"); - const char *fp = ha_obj_str(r, "file_path"); - const char *lb = ha_obj_str(r, "label"); - const char *disp = (qn && qn[0]) ? qn : (nm ? nm : ""); - char safe_disp[HA_METADATA_CAP]; - char safe_path[HA_METADATA_CAP]; - char safe_label[HA_METADATA_CAP]; - ha_sanitize_metadata(disp, safe_disp, sizeof(safe_disp)); - ha_sanitize_metadata(fp, safe_path, sizeof(safe_path)); - ha_sanitize_metadata(lb, safe_label, sizeof(safe_label)); - off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", safe_disp, safe_path, - safe_label[0] ? " " : "", safe_label); } yyjson_doc_free(idoc); @@ -1222,6 +1247,15 @@ char *cbm_hook_augment_lifecycle_json_for_dialect(const char *input, const char return json; } +/* Test seam: run the real envelope->additionalContext formatter. Couples the + * test suite to the ACTUAL search_graph response shape — a format change that + * breaks this parser breaks a test locally, not just the Windows CI guard. */ +char *cbm_hook_augment_format_context_for_testing(const char *envelope, const char *token, + bool *is_error) { + bool dummy = false; + return ha_format_context(envelope, token, is_error ? is_error : &dummy); +} + char *cbm_hook_augment_tool_json_for_testing(const char *input, const char *dialect_name, const char *context, char *path, size_t path_size) { if (!input || !context || !path || path_size == 0U || strlen(input) > HA_STDIN_CAP) { diff --git a/tests/test_cli.c b/tests/test_cli.c index bb8a94b6f..c6d72757d 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -13,6 +13,7 @@ #include "test_framework.h" #include "test_helpers.h" #include +#include #include #include #include @@ -6910,6 +6911,48 @@ TEST(cli_codex_migrates_to_single_hook_representation) { PASS(); } +/* The PreToolUse augmenter parses search_graph's format:"json" payload to + * build additionalContext. This test feeds it the REAL envelope from a live + * in-memory server, so any drift between the response shape and the parser + * fails HERE — not only in the Windows CI guard (which caught exactly that: + * the json-tree reshape left the parser reading a key that no longer exists, + * and the hook silently emitted nothing). */ +TEST(cli_hook_augment_context_tracks_search_json_shape) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + const char *proj = "hookproj"; + cbm_mcp_server_set_project(srv, proj); + cbm_store_upsert_project(st, proj, "/tmp/hookproj"); + cbm_node_t n = {.project = proj, + .label = "Function", + .name = "someIndexedSymbol", + .qualified_name = "hookproj.mod.someIndexedSymbol", + .file_path = "mod.py", + .start_line = 1, + .end_line = 4}; + ASSERT_GT(cbm_store_upsert_node(st, &n), 0); + + /* The exact request ha_build_args produces: format:"json". */ + char *envelope = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"hookproj\",\"name_pattern\":\".*someIndexedSymbol.*\"," + "\"limit\":5,\"format\":\"json\"}"); + ASSERT_NOT_NULL(envelope); + + bool is_error = true; + char *ctx = + cbm_hook_augment_format_context_for_testing(envelope, "someIndexedSymbol", &is_error); + ASSERT_FALSE(is_error); + ASSERT_NOT_NULL(ctx); /* one hit MUST produce context — empty = broken hook */ + ASSERT_NOT_NULL(strstr(ctx, "someIndexedSymbol")); + ASSERT_NOT_NULL(strstr(ctx, "mod.py")); + free(ctx); + free(envelope); + cbm_mcp_server_free(srv); + PASS(); +} + TEST(cli_hook_augment_lifecycle_output_contract) { static const struct { const char *event; @@ -9819,6 +9862,7 @@ SUITE(cli) { RUN_TEST(cli_claude_hook_scripts_shell_quote_binary_path); RUN_TEST(cli_claude_hook_commands_shell_quote_custom_config_dir); RUN_TEST(cli_codex_migrates_to_single_hook_representation); + RUN_TEST(cli_hook_augment_context_tracks_search_json_shape); RUN_TEST(cli_hook_augment_lifecycle_output_contract); RUN_TEST(cli_hook_augment_subagent_tier_router_contract); RUN_TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only);