feat(mcp): make tool output lean and lossless

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-09-05 06:05:08 +02:00
1 parent ceba42728a
commit 3909e5a0e1
33 files changed
+9310 -1641

No files matched your search

+7 -2
View File
@@ -629,7 +629,11 @@ Every MCP tool can be invoked as a local, one-shot command. CLI tools neither st
Commands that mutate graph data use shared OS-backed, per-project locks. This serializes conflicting work from CLI and MCP sessions on the same project while allowing unrelated projects to proceed independently.
When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Progress is written only to stderr; stdout remains reserved for the command result, so pipes and scripts stay machine-safe. Pass `--json` when the full MCP result envelope is needed.
When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Routine informational logs are quiet by default; pass outer `cli --verbose` to include them. Progress and logs use stderr while stdout remains reserved for the command result. Read tools return a compact tree by default; pass a tool's `--format json` for machine-readable payload JSON, or outer `--json` for the full MCP envelope.
Large compact-tree tables may start with a response-local `<section>_refs` directory and an explicit `<section>_ref_rule`. A cell such as `@0+handler.go` reconstructs to ref `0`'s prefix plus `handler.go`. References are local to that sibling `<section>` table and expansion is non-recursive: entries inside `<section>_refs` are always literal prefixes. This is limited to declared path and qualified-name columns and activates only when that table is at least 15% and 64 bytes smaller. Search and trace likewise render direct and prefix-grouped tree shapes and keep the smaller complete representation, so singleton or scattered answers do not pay directory overhead. Keys are declared once per table but never cryptically abbreviated, and `--format json` keeps stable literal strings for machine consumers. These are deterministic model-neutral byte savings that normally reduce tokens; exact token counts still depend on the caller's tokenizer.
Lean responses truncate semantically, never by cutting arbitrary bytes from code or identifiers. Ranked graph rows are retained ahead of raw grep rows and diagnostic summaries; omitted rows/sections report totals, `has_more`, and a strictly advancing continuation offset or cursor. If even the first whole row cannot fit, CBM asks for a higher budget and emits no self-looping cursor. `max_output_tokens` is model-neutral sizing guidance: CBM enforces a deterministic ceiling of four UTF-8 bytes per requested token, so it is not a tokenizer-exact count. Detail flags such as `diagnostics`, `source_mode`, and `detail` opt into heavier fields. `search_code` additionally exposes `raw_limit`, `directory_limit`, `match_limit`, and `source_max_lines`; its default retains eight match locations per graph result and reports the exact `matches_omitted` remainder. `detect_changes` pages changed files, impacted symbols, and module summaries independently.
Use `cli <tool> --help` to see the flags generated from that tool's input schema:
@@ -644,7 +648,8 @@ codebase-memory-mcp cli query_graph --project my-project --query 'MATCH (f:Funct
# Force human-readable progress without contaminating stdout.
codebase-memory-mcp cli --progress index_repository --repo-path /path/to/repo
codebase-memory-mcp cli search_graph --project my-project --label Function | jq '.results[].name'
codebase-memory-mcp cli search_graph --project my-project --label Function --format json
codebase-memory-mcp cli list_projects --format json --detail stats | jq '.projects[].name'
```
JSON arguments can also be piped on stdin, for tools that take arguments. A tool whose input schema declares none — `list_projects` — never reads stdin, so it stays responsive when it inherits a pipe the caller never closes (the default for `child_process.spawn` and similar wrappers). Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin.
@@ -68,6 +68,12 @@ describe("NodeDetailPanel code preview + deep-link", () => {
/* …but was NOT parsed into a real <script> element, and did not execute. */
expect(container.querySelector("script")).toBeNull();
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
expect(callToolMock).toHaveBeenCalledWith("get_code_snippet", {
qualified_name: "app::render",
project: "demo",
format: "json",
source_mode: "full",
});
});
it("builds an https GitHub deep-link with URL-encoded path segments", () => {
@@ -76,6 +76,8 @@ export function NodeDetailPanel({
const res = await callTool<SnippetResult>("get_code_snippet", {
qualified_name: node.qualified_name,
project,
format: "json",
source_mode: "full",
});
setCode(res.source ?? "(source not available)");
} catch (e) {
+5 -2
View File
@@ -12,7 +12,7 @@ function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response
if (overridden) return overridden;
if (url === "/rpc") {
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
result: { content: [{ text: JSON.stringify({ projects: [], has_more: false }) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/ui-config")) {
@@ -221,8 +221,11 @@ describe("StatsTab index modal", () => {
root_path: "/repo",
indexed_at: "2026-01-01T00:00:00Z",
}],
has_more: false,
}
: { node_labels: [], edge_types: [], total_nodes: 0, total_edges: 0 };
: {
node_labels: [], edge_types: [], total_nodes: 0, total_edges: 0, has_more: false,
};
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify(result) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
+86
View File
@@ -0,0 +1,86 @@
/* @vitest-environment jsdom */
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProjects } from "./useProjects";
const callToolMock = vi.fn();
vi.mock("../api/rpc", () => ({
callTool: (...args: unknown[]) => callToolMock(...args),
}));
describe("useProjects machine-readable pagination", () => {
beforeEach(() => callToolMock.mockReset());
it("requests JSON and merges every project and schema page", async () => {
callToolMock.mockImplementation(async (name: string, args: Record<string, unknown>) => {
if (name === "list_projects") {
if (args.offset === 0) {
return {
projects: [{ name: "alpha", root_path: "/alpha", indexed_at: "now" }],
has_more: true,
next_offset: 1,
};
}
return {
projects: [{ name: "beta", root_path: "/beta", indexed_at: "now" }],
has_more: false,
};
}
if (name === "get_graph_schema" && args.project === "alpha") {
if (args.offset === 0) {
return {
node_labels: [{ label: "Function", count: 3 }],
edge_types: [],
total_nodes: 3,
total_edges: 2,
has_more: true,
next_offset: 1,
};
}
return {
node_labels: [],
edge_types: [{ type: "CALLS", count: 2 }],
total_nodes: 3,
total_edges: 2,
has_more: false,
};
}
return {
node_labels: [{ label: "Class", count: 1 }],
edge_types: [],
total_nodes: 1,
total_edges: 0,
has_more: false,
};
});
const { result } = renderHook(() => useProjects());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.projects).toHaveLength(2);
expect(result.current.projects[0].schema?.node_labels).toEqual([
{ label: "Function", count: 3 },
]);
expect(result.current.projects[0].schema?.edge_types).toEqual([
{ type: "CALLS", count: 2 },
]);
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
format: "json",
detail: "stats",
limit: 500,
offset: 0,
});
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
format: "json",
detail: "stats",
limit: 500,
offset: 1,
});
expect(callToolMock).toHaveBeenCalledWith("get_graph_schema", {
project: "alpha",
format: "json",
limit: 500,
offset: 1,
});
});
});
+66 -5
View File
@@ -7,6 +7,70 @@ interface ProjectInfo {
schema: SchemaInfo | null;
}
interface ProjectPage {
projects?: Project[];
has_more?: boolean;
next_offset?: number;
}
interface SchemaPage extends SchemaInfo {
has_more?: boolean;
next_offset?: number;
}
const PAGE_LIMIT = 500;
function nextPageOffset(page: { has_more?: boolean; next_offset?: number }, offset: number) {
if (typeof page.has_more !== "boolean") {
throw new Error("Invalid pagination response");
}
if (!page.has_more) return null;
if (!Number.isInteger(page.next_offset) || page.next_offset! <= offset) {
throw new Error("Invalid pagination response");
}
return page.next_offset!;
}
async function fetchAllProjects(): Promise<Project[]> {
const projects: Project[] = [];
let offset = 0;
for (;;) {
const page = await callTool<ProjectPage>("list_projects", {
format: "json",
detail: "stats",
limit: PAGE_LIMIT,
offset,
});
projects.push(...(page.projects ?? []));
const next = nextPageOffset(page, offset);
if (next === null) return projects;
offset = next;
}
}
async function fetchFullSchema(project: string): Promise<SchemaInfo> {
const nodeLabels: SchemaInfo["node_labels"] = [];
const edgeTypes: SchemaInfo["edge_types"] = [];
let firstPage: SchemaPage | null = null;
let offset = 0;
for (;;) {
const page = await callTool<SchemaPage>("get_graph_schema", {
project,
format: "json",
limit: PAGE_LIMIT,
offset,
});
firstPage ??= page;
nodeLabels.push(...(page.node_labels ?? []));
edgeTypes.push(...(page.edge_types ?? []));
const next = nextPageOffset(page, offset);
if (next === null) {
return { ...firstPage, node_labels: nodeLabels, edge_types: edgeTypes };
}
offset = next;
}
}
interface UseProjectsResult {
projects: ProjectInfo[];
loading: boolean;
@@ -23,16 +87,13 @@ export function useProjects(): UseProjectsResult {
setLoading(true);
setError(null);
try {
const result = await callTool<{ projects: Project[] }>("list_projects");
const list = result.projects ?? [];
const list = await fetchAllProjects();
/* Fetch schema for each project */
const infos: ProjectInfo[] = await Promise.all(
list.map(async (p) => {
try {
const schema = await callTool<SchemaInfo>("get_graph_schema", {
project: p.name,
});
const schema = await fetchFullSchema(p.name);
return { project: p, schema };
} catch {
return { project: p, schema: null };
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/displaysettingsmenu.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphloader.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.deadcode.test.tsx","./src/components/graphtab.filters.test.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/missedcallout.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.test.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.test.tsx","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/density.test.ts","./src/lib/density.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
+7 -5
View File
@@ -407,7 +407,7 @@ DUP_CHECKED=0
for TOOL_ARGS in "search_graph --project $PROJECT --name-pattern compute" \
"search_code --project $PROJECT --query compute" \
"get_architecture --project $PROJECT" \
"index_status --project $PROJECT"; do
"index_status --project $PROJECT --format json"; do
# shellcheck disable=SC2086
ENVELOPE=$("$BINARY" cli $TOOL_ARGS --json 2>/dev/null || true)
[ -z "$ENVELOPE" ] && continue
@@ -575,7 +575,7 @@ fi
echo "OK: trace_path found $CALLERS caller(s) for 'compute'"
# 3c: get_graph_schema — verify labels exist
if ! SCHEMA=$(cli get_graph_schema --project "$PROJECT"); then
if ! SCHEMA=$(cli get_graph_schema --project "$PROJECT" --format json --limit 500); then
echo "FAIL: get_graph_schema (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
LABELS=$(echo "$SCHEMA" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('node_labels',[])))" 2>/dev/null || echo "0")
@@ -647,8 +647,10 @@ cyp_first_cell() {
# $1 = query; echoes rows[0][0] (or empty). Flag form passes the query as ONE
# 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'
cli query_graph --project "$PROJECT" --query "$1" --format json |
python3 -c 'import json,sys
d=json.load(sys.stdin); rows=d.get("rows", [])
print(rows[0][0] if rows and rows[0] else "")'
}
# labels(n) → JSON list like ["Function"]
@@ -851,7 +853,7 @@ echo "OK B4: compact STDIN + --json returns a successful schema MCP envelope, no
# B5: --args-file — JSON read from a file resolves; must NOT warn deprecated.
IM_ARGS_FILE=$(smoke_mktemp_file)
echo "{\"project\":\"$PROJECT\"}" > "$IM_ARGS_FILE"
echo "{\"project\":\"$PROJECT\",\"format\":\"json\",\"limit\":500}" > "$IM_ARGS_FILE"
if ! IM_AF=$(cli get_graph_schema --args-file "$IM_ARGS_FILE"); then
echo "FAIL B5: get_graph_schema --args-file exited non-zero"; cat "$CLI_STDERR"; rm -f "$IM_ARGS_FILE"; exit 1
fi
+132 -38
View File
@@ -27,6 +27,7 @@
#include <ctype.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -45,6 +46,7 @@
#define HA_MIN_TOKEN 4 /* skip short/noisy patterns before any work */
#define HA_MAX_TOKEN 96
#define HA_RESULT_LIMIT 5
#define HA_LIST_PAGE_LIMIT 500
#define HA_METADATA_CAP 192
#define HA_MAX_WALKUP 8 /* cwd may be a subdir of the indexed root */
#define HA_DEADLINE_MS 300 /* hard in-process budget (see also: the */
@@ -511,11 +513,45 @@ static char *ha_coverage_context(const char *envelope, const char *rel, bool *is
strcmp(status, "invalid_path") != 0) {
const char *kind = NULL;
const char *detail = NULL;
char *range_detail = NULL;
yyjson_val *coverage = yyjson_obj_get(item, "coverage");
yyjson_val *row = coverage && yyjson_is_arr(coverage) ? yyjson_arr_get(coverage, 0) : NULL;
if (row) {
kind = ha_obj_str(row, "kind");
detail = ha_obj_str(row, "detail");
yyjson_val *ranges = yyjson_obj_get(row, "ranges");
size_t range_count = ranges && yyjson_is_arr(ranges) ? yyjson_arr_size(ranges) : 0;
if ((!detail || !detail[0]) && range_count > 0 && range_count <= SIZE_MAX / 48U) {
range_detail = calloc(range_count * 48U + 1U, 1U);
if (range_detail) {
size_t used = 0;
size_t index;
size_t maximum;
yyjson_val *range;
yyjson_arr_foreach(ranges, index, maximum, range) {
yyjson_val *start_value = yyjson_obj_get(range, "start");
yyjson_val *end_value = yyjson_obj_get(range, "end");
if (!yyjson_is_int(start_value) || !yyjson_is_int(end_value)) {
continue;
}
long long start = (long long)yyjson_get_sint(start_value);
long long end = (long long)yyjson_get_sint(end_value);
int written =
start == end
? snprintf(range_detail + used, range_count * 48U + 1U - used,
"%s%lld", used ? "," : "", start)
: snprintf(range_detail + used, range_count * 48U + 1U - used,
"%s%lld-%lld", used ? "," : "", start, end);
if (written < 0 || (size_t)written >= range_count * 48U + 1U - used) {
free(range_detail);
range_detail = NULL;
break;
}
used += (size_t)written;
}
detail = range_detail;
}
}
}
text = malloc(1536);
if (text) {
@@ -542,6 +578,7 @@ static char *ha_coverage_context(const char *envelope, const char *rel, bool *is
status, freshness ? freshness : "unavailable");
}
}
free(range_detail);
}
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
@@ -611,6 +648,7 @@ static char *ha_resolve_coverage(cbm_mcp_server_t *srv, const char *file_path) {
yyjson_mut_obj_add_str(adoc, aroot, "project", project);
yyjson_mut_arr_add_strcpy(adoc, paths, rel);
yyjson_mut_obj_add_val(adoc, aroot, "paths", paths);
yyjson_mut_obj_add_str(adoc, aroot, "format", "json");
char *args = yyjson_mut_write(adoc, 0, NULL);
yyjson_mut_doc_free(adoc);
free(project);
@@ -811,53 +849,108 @@ static char *ha_registry_project_for_path(cbm_mcp_server_t *srv, const char *cwd
if (!ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) {
return NULL;
}
char *envelope = cbm_mcp_handle_tool(srv, "list_projects", "{\"metadata_only\":true}");
yyjson_doc *doc = envelope ? yyjson_read(envelope, strlen(envelope), 0) : NULL;
free(envelope);
if (!doc) {
return NULL;
}
yyjson_val *outer = yyjson_doc_get_root(doc);
yyjson_val *error = yyjson_obj_get(outer, "isError");
yyjson_val *structured = yyjson_obj_get(outer, "structuredContent");
yyjson_val *projects = structured ? yyjson_obj_get(structured, "projects") : NULL;
if ((error && yyjson_is_true(error)) || !projects || !yyjson_is_arr(projects)) {
yyjson_doc_free(doc);
return NULL;
}
const char *best_name = NULL;
char *best_name = NULL;
char best_root[4096] = {0};
size_t best_length = 0U;
size_t index;
size_t maximum;
yyjson_val *project;
yyjson_arr_foreach(projects, index, maximum, project) {
const char *name = ha_obj_str(project, "name");
const char *root = ha_obj_str(project, "root_path");
char canonical_root[4096];
if (!name || !name[0] || !root || !root[0] ||
!ha_canonical_path(root, canonical_root, sizeof(canonical_root)) ||
!ha_path_contains(canonical_root, canonical_cwd)) {
continue;
int64_t offset = 0;
bool complete = false;
for (;;) {
char args[160];
int written = snprintf(args, sizeof(args),
"{\"metadata_only\":true,\"format\":\"json\",\"limit\":%d,"
"\"offset\":%lld}",
HA_LIST_PAGE_LIMIT, (long long)offset);
if (written < 0 || (size_t)written >= sizeof(args)) {
break;
}
size_t length = strlen(canonical_root);
if (length > best_length) {
best_name = name;
best_length = length;
snprintf(best_root, sizeof(best_root), "%s", canonical_root);
char *envelope = cbm_mcp_handle_tool(srv, "list_projects", args);
yyjson_doc *edoc = envelope ? yyjson_read(envelope, strlen(envelope), 0) : NULL;
free(envelope);
if (!edoc) {
break;
}
yyjson_val *outer = yyjson_doc_get_root(edoc);
yyjson_val *error = yyjson_obj_get(outer, "isError");
yyjson_val *content = yyjson_obj_get(outer, "content");
yyjson_val *item0 = content && yyjson_is_arr(content) ? yyjson_arr_get(content, 0) : NULL;
const char *inner = ha_obj_str(item0, "text");
yyjson_doc *idoc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
if ((error && yyjson_is_true(error)) || !idoc) {
if (idoc) {
yyjson_doc_free(idoc);
}
yyjson_doc_free(edoc);
break;
}
yyjson_val *root = yyjson_doc_get_root(idoc);
yyjson_val *projects = yyjson_obj_get(root, "projects");
if (!projects || !yyjson_is_arr(projects)) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
break;
}
size_t index;
size_t maximum;
yyjson_val *project;
yyjson_arr_foreach(projects, index, maximum, project) {
const char *name = ha_obj_str(project, "name");
const char *project_root = ha_obj_str(project, "root_path");
char canonical_root[4096];
if (!name || !name[0] || !project_root || !project_root[0] ||
!ha_canonical_path(project_root, canonical_root, sizeof(canonical_root)) ||
!ha_path_contains(canonical_root, canonical_cwd)) {
continue;
}
size_t length = strlen(canonical_root);
if (length > best_length) {
char *candidate = strdup(name);
if (!candidate) {
continue;
}
free(best_name);
best_name = candidate;
best_length = length;
snprintf(best_root, sizeof(best_root), "%s", canonical_root);
}
}
yyjson_val *has_more = yyjson_obj_get(root, "has_more");
if (!has_more || !yyjson_is_bool(has_more)) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
break;
}
bool more = yyjson_is_true(has_more);
if (!more) {
complete = true;
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
break;
}
yyjson_val *next = yyjson_obj_get(root, "next_offset");
int64_t next_offset = next && yyjson_is_int(next) ? yyjson_get_int(next) : -1;
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
if (next_offset <= offset) {
break;
}
offset = next_offset;
}
char *result = best_name ? strdup(best_name) : NULL;
if (result && root_out && root_out_size > 0U) {
if (!complete) {
free(best_name);
return NULL;
}
if (best_name && root_out && root_out_size > 0U) {
int written = snprintf(root_out, root_out_size, "%s", best_root);
if (written < 0 || (size_t)written >= root_out_size) {
free(result);
result = NULL;
free(best_name);
best_name = NULL;
}
}
yyjson_doc_free(doc);
return result;
return best_name;
}
/* Return the nearest indexed graph project for cwd. Probe derived names first
@@ -878,6 +971,7 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c
if (doc && root) {
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_obj_add_str(doc, root, "project", project);
yyjson_mut_obj_add_str(doc, root, "format", "json");
char *args = yyjson_mut_write(doc, 0, NULL);
yyjson_mut_doc_free(doc);
if (args) {
+41
View File
@@ -31,6 +31,8 @@ static FILE *s_out;
static atomic_int s_needs_newline;
static int s_gbuf_nodes = NOT_SET;
static int s_gbuf_edges = NOT_SET;
static CBMLogLevel s_previous_log_level = CBM_LOG_WARN;
static bool s_raised_log_level;
static cbm_mutex_t s_sink_mutex;
static atomic_int s_sink_mutex_state = ATOMIC_VAR_INIT(LOCK_UNINITIALIZED);
@@ -184,6 +186,13 @@ void cbm_progress_sink_init(FILE *out) {
atomic_store(&s_needs_newline, 0);
s_gbuf_nodes = NOT_SET;
s_gbuf_edges = NOT_SET;
s_previous_log_level = cbm_log_get_level();
s_raised_log_level = s_previous_log_level > CBM_LOG_INFO;
if (s_raised_log_level) {
/* Progress is an explicit INFO opt-in. The replacement sink below
* keeps only recognized lifecycle events plus warnings/errors. */
cbm_log_set_level(CBM_LOG_INFO);
}
cbm_log_set_sink(cbm_progress_sink_fn);
cbm_mutex_unlock(&s_sink_mutex);
}
@@ -197,6 +206,10 @@ void cbm_progress_sink_fini(void) {
(void)fflush(s_out);
}
s_out = NULL;
if (s_raised_log_level) {
cbm_log_set_level(s_previous_log_level);
s_raised_log_level = false;
}
cbm_mutex_unlock(&s_sink_mutex);
}
@@ -338,6 +351,19 @@ static const event_handler_t handlers[] = {
enum { HANDLER_COUNT = sizeof(handlers) / sizeof(handlers[0]) };
/* The progress sink replaces normal logger output. Preserve actionable
* failures verbatim, but keep routine worker chatter out of the human progress
* view. Logger callbacks do not include a trailing newline. */
static void passthrough_failure(const char *line) {
flush_carriage();
(void)fputs(line, s_out);
size_t line_len = strlen(line);
if (line_len == 0 || line[line_len - 1] != '\n') {
(void)fputc('\n', s_out);
}
(void)fflush(s_out);
}
void cbm_progress_sink_fn(const char *line) {
progress_sink_mutex_ensure();
cbm_mutex_lock(&s_sink_mutex);
@@ -345,6 +371,21 @@ void cbm_progress_sink_fn(const char *line) {
cbm_mutex_unlock(&s_sink_mutex);
return;
}
char level[CBM_SZ_32] = {0};
if (!extract_kv(line, "level", level, (int)sizeof(level))) {
cbm_mutex_unlock(&s_sink_mutex);
return;
}
if (strcmp(level, "warn") == 0 || strcmp(level, "error") == 0) {
passthrough_failure(line);
cbm_mutex_unlock(&s_sink_mutex);
return;
}
if (strcmp(level, "info") != 0) {
cbm_mutex_unlock(&s_sink_mutex);
return;
}
char msg[CBM_SZ_64] = {0};
if (!extract_kv(line, "msg", msg, (int)sizeof(msg))) {
cbm_mutex_unlock(&s_sink_mutex);
+65 -6
View File
@@ -7,6 +7,7 @@
*/
#include "cypher/cypher.h"
#include "foundation/compat.h"
#include "foundation/constants.h"
#include "store/store.h"
#include "foundation/platform.h"
#include "foundation/limits.h"
@@ -2880,6 +2881,9 @@ static void rb_add_row(result_builder_t *rb, const char **values) {
static _Thread_local uint64_t g_cypher_deadline_ms = 0; /* absolute; 0 = disarmed */
static _Thread_local bool g_cypher_timed_out = false;
/* Sticky for one cbm_cypher_execute call. Unlike row_count, this survives caps
* that happen before DISTINCT, aggregation, ORDER BY, or the final projection. */
static _Thread_local bool g_cypher_truncated = false;
static _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */
static void cypher_deadline_arm(void) {
@@ -3494,7 +3498,8 @@ static void rb_apply_order_by(result_builder_t *rb, const cbm_return_clause_t *r
}
}
static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit) {
static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit,
bool limit_is_engine_budget) {
/* Skip */
if (skip_n > 0 && skip_n < rb->row_count) {
for (int i = 0; i < skip_n; i++) {
@@ -3516,6 +3521,9 @@ static void rb_apply_skip_limit(result_builder_t *rb, int skip_n, int limit) {
}
/* Limit */
if (limit >= 0 && rb->row_count > limit) {
if (limit_is_engine_budget) {
g_cypher_truncated = true;
}
for (int i = limit; i < rb->row_count; i++) {
for (int c = 0; c < rb->col_count; c++) {
safe_str_free(&rb->rows[i][c]);
@@ -3759,6 +3767,25 @@ static const char *project_item(binding_t *b, cbm_return_item_t *item, char *fun
if (is_scalar_value_func(item->func)) {
return apply_string_func(item->func, raw, func_buf, buf_sz);
}
/* Direct node/edge fields live in the binding until this row has been
* copied by rb_add_row. Keep those allocation-backed values intact: the
* fixed per-column scratch buffer exists only to stabilize computed and
* JSON-derived rotating-buffer values. Copying every value through it
* silently clipped long qualified names and paths to 511 bytes before the
* MCP response budget or prefix directory ever saw them. */
for (int i = 0; raw && raw[0] && i < b->var_count; i++) {
const cbm_node_t *node = &b->var_nodes[i];
if (raw == node->project || raw == node->label || raw == node->name ||
raw == node->qualified_name || raw == node->file_path || raw == node->properties_json) {
return raw;
}
}
for (int i = 0; raw && raw[0] && i < b->edge_var_count; i++) {
const cbm_edge_t *edge = &b->edge_vars[i];
if (raw == edge->project || raw == edge->type || raw == edge->properties_json) {
return raw;
}
}
/* Copy into the caller's per-column buffer. `raw` may point to node_prop's
* rotating scratch buffer, which the next column's projection would overwrite
* before rb_add_row copies the assembled row — aliasing every such column to
@@ -4320,11 +4347,22 @@ static void execute_return_star(cbm_query_t *q, binding_t *bindings, int bind_co
const char *vars[CBM_SZ_32];
int vc = collect_pattern_vars(q, vars, CBM_SZ_32);
build_star_columns(rb, vars, vc);
for (int bi = 0; bi < bind_count && rb->row_count < max_rows; bi++) {
int projection_cap = max_rows;
bool cap_is_engine_budget = true;
cbm_return_clause_t *ret = q->ret;
if (ret && ret->limit >= 0 && ret->limit <= max_rows && !ret->distinct &&
ret->order_key_count == 0 && ret->skip <= 0) {
projection_cap = ret->limit;
cap_is_engine_budget = false;
}
for (int bi = 0; bi < bind_count && rb->row_count < projection_cap; bi++) {
const char *vals[CBM_SZ_128];
project_star_row(&bindings[bi], vars, vc, vals);
rb_add_row(rb, vals);
}
if (cap_is_engine_budget && bind_count > projection_cap) {
g_cypher_truncated = true;
}
}
/* Format an aggregate value into buf based on function name */
@@ -4570,8 +4608,10 @@ static void build_return_columns(result_builder_t *rb, cbm_return_clause_t *ret)
static void execute_return_simple(cbm_return_clause_t *ret, binding_t *bindings, int bind_count,
int max_rows, result_builder_t *rb) {
int proj_cap = max_rows;
if (ret->limit > 0 && !ret->distinct && ret->order_key_count == 0 && ret->skip <= 0) {
bool cap_is_engine_budget = true;
if (ret->limit >= 0 && !ret->distinct && ret->order_key_count == 0 && ret->skip <= 0) {
proj_cap = ret->limit;
cap_is_engine_budget = false;
}
for (int bi = 0; bi < bind_count && rb->row_count < proj_cap; bi++) {
const char *vals[CBM_SZ_32];
@@ -4582,6 +4622,9 @@ static void execute_return_simple(cbm_return_clause_t *ret, binding_t *bindings,
}
rb_add_row(rb, vals);
}
if (cap_is_engine_budget && bind_count > proj_cap) {
g_cypher_truncated = true;
}
}
/* Build default 3-column headers (name, qualified_name, label) per variable */
@@ -4608,9 +4651,13 @@ static void execute_default_projection(cbm_pattern_t *pat0, binding_t *bindings,
int max_rows, result_builder_t *rb) {
const char *vars[CYP_MAX_VARS];
int vc = 0;
for (int ni = 0; ni < pat0->node_count && vc < CYP_MAX_VARS; ni++) {
for (int ni = 0; ni < pat0->node_count; ni++) {
if (pat0->nodes[ni].variable) {
vars[vc++] = pat0->nodes[ni].variable;
if (vc < CYP_MAX_VARS) {
vars[vc++] = pat0->nodes[ni].variable;
} else {
g_cypher_truncated = true;
}
}
}
build_default_columns(rb, vars, vc);
@@ -4625,6 +4672,9 @@ static void execute_default_projection(cbm_pattern_t *pat0, binding_t *bindings,
}
rb_add_row(rb, vals);
}
if (bind_count > max_rows) {
g_cypher_truncated = true;
}
}
/* Worst-case binding_t slot count for a node cross-join: one row per
@@ -4917,7 +4967,9 @@ static void execute_return_clause(cbm_query_t *q, cbm_return_clause_t *ret, bind
rb_apply_distinct(rb);
}
rb_apply_order_by(rb, ret);
rb_apply_skip_limit(rb, ret->skip, ret->limit >= 0 ? ret->limit : max_rows);
bool limit_is_engine_budget = ret->limit < 0;
rb_apply_skip_limit(rb, ret->skip, limit_is_engine_budget ? max_rows : ret->limit,
limit_is_engine_budget);
}
static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *project, int max_rows,
@@ -5154,6 +5206,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
memset(out, 0, sizeof(*out));
g_cypher_depth_clamped = 0;
g_cypher_trail_truncated = 0;
g_cypher_truncated = false;
cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */
if (max_rows <= 0) {
max_rows = CYPHER_RESULT_CEILING;
@@ -5179,6 +5232,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
if (execute_single(store, q, project, max_rows, &rb) < 0) {
rb_free(&rb);
cbm_query_free(q);
out->truncated = g_cypher_truncated;
out->error = heap_strdup("query aborted: out of memory or an allocation limit was reached");
return CBM_NOT_FOUND;
}
@@ -5191,6 +5245,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
rb_free(&rb);
rb_free(&rb2);
cbm_query_free(q);
out->truncated = g_cypher_truncated;
out->error =
heap_strdup("query aborted: out of memory or an allocation limit was reached");
return CBM_NOT_FOUND;
@@ -5214,6 +5269,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
if (g_cypher_timed_out) {
rb_free(&rb);
cbm_query_free(q);
out->truncated = g_cypher_truncated;
out->error =
heap_strdup("query exceeded the execution time limit — narrow the pattern with a WHERE "
"filter, use a directed MATCH instead of an unbounded OPTIONAL MATCH, or "
@@ -5223,8 +5279,10 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
/* Check ceiling */
if (rb.row_count >= CYPHER_RESULT_CEILING) {
g_cypher_truncated = true;
rb_free(&rb);
cbm_query_free(q);
out->truncated = true;
out->error = heap_strdup("result exceeded 100k rows — use narrower filters or add LIMIT");
return CBM_NOT_FOUND;
}
@@ -5233,6 +5291,7 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
out->col_count = rb.col_count;
out->rows = rb.rows;
out->row_count = rb.row_count;
out->truncated = g_cypher_truncated || g_cypher_trail_truncated != 0;
if (g_cypher_depth_clamped > 0 || g_cypher_trail_truncated) {
char wbuf[CBM_SZ_256];
if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) {
+4
View File
@@ -321,6 +321,10 @@ typedef struct {
/* rows[row_idx][col_idx] = string value */
const char ***rows;
int row_count;
/* True when an internal row/candidate ceiling prevented exhaustive
* evaluation. A Cypher LIMIT is part of query semantics and does not set
* this flag by itself. */
bool truncated;
/* Non-NULL when the query was rejected (e.g. result too large) */
char *error;
/* Non-NULL advisory (caller-visible, not an error): e.g. a variable-
+1 -1
View File
@@ -23,7 +23,7 @@
* Relaxed ordering is the right level: each is an independent scalar with no
* happens-before relationship to publish alongside it, and the log path must
* stay cheap enough that nobody is tempted to route around it. */
static _Atomic CBMLogLevel g_log_level = CBM_LOG_INFO;
static _Atomic CBMLogLevel g_log_level = CBM_LOG_WARN;
static _Atomic CBMLogFormat g_log_format = CBM_LOG_FORMAT_TEXT;
/* Cast, not bare NULL: NULL is ((void*)0) and the implicit void*-to-
* function-pointer conversion is not a compile-time constant, which
+1 -1
View File
@@ -44,7 +44,7 @@ typedef enum {
* unchanged. Call once at startup before any threads or log lines. */
void cbm_log_init_from_env(void);
/* Set minimum log level (default: INFO). */
/* Set minimum log level (default: WARN; INFO is opt-in). */
void cbm_log_set_level(CBMLogLevel level);
/* Get current log level. */
+33 -5
View File
@@ -6,6 +6,7 @@
* cli <tool> <json> Run a single tool call and print result
* --version Print version and exit
* --help Print usage and exit
* cli --verbose Include informational logs for one-shot commands
* --ui=true/false Enable/disable HTTP UI server (persisted)
* --port=N Set HTTP UI port (persisted, default 9749)
* --tool-profile=analysis|scout Expose a restricted agent tool surface
@@ -539,7 +540,11 @@ static bool client_start_parent_watchdog(pid_t initial_ppid) {
/* ── CLI mode ───────────────────────────────────────────────────── */
#define CLI_USAGE "Usage: codebase-memory-mcp cli [--progress] [--json] <tool_name> [json_args]\n"
#define CLI_USAGE \
"Usage: codebase-memory-mcp cli [--progress] [--verbose] [--json] <tool_name> [json_args]\n" \
" --progress Show lifecycle progress even when stderr is redirected\n" \
" --verbose Include informational logs (preserves CBM_LOG_LEVEL=debug)\n" \
" --json Print the raw MCP result envelope\n"
/* Extract text content from MCP tool result envelope and print it.
* MCP results: {"content":[{"type":"text","text":"..."}],"isError":...}
@@ -587,6 +592,24 @@ static bool cli_strip_flag(int *argc, char **argv, const char *flag) {
return false;
}
/* Outer CLI flags precede the tool name. Keep this distinct from
* cli_strip_flag(): several MCP tools have their own `verbose` input, so
* `cli index_status --verbose` must remain a tool argument while
* `cli --verbose index_status` configures process logging. */
static bool cli_strip_outer_flag(int *argc, char **argv, const char *flag) {
for (int i = 0; i < *argc && argv[i] && argv[i][0] == '-'; i++) {
if (strcmp(argv[i], flag) != 0) {
continue;
}
for (int j = i; j < *argc - SKIP_ONE; j++) {
argv[j] = argv[j + SKIP_ONE];
}
(*argc)--;
return true;
}
return false;
}
/* Strip a flag AND its following value from argv, returning the value (a pointer
* into the original argv strings, valid for the process lifetime) or NULL if the
* flag is absent. */
@@ -669,6 +692,10 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar
static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_locks,
main_local_maintenance_context_t *maintenance_context) {
bool verbose_requested = cli_strip_outer_flag(&argc, argv, "--verbose");
if (verbose_requested && cbm_log_get_level() > CBM_LOG_INFO) {
cbm_log_set_level(CBM_LOG_INFO);
}
if (argc == 1 && argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) {
(void)fputs(CLI_USAGE, stdout);
return 0;
@@ -898,7 +925,7 @@ static void print_help(void) {
printf("codebase-memory-mcp %s\n\n", CBM_VERSION);
printf("Usage:\n");
printf(" codebase-memory-mcp Run MCP server on stdio\n");
printf(" codebase-memory-mcp cli [--progress] [--json] <tool> [args]\n");
printf(" codebase-memory-mcp cli [--progress] [--verbose] [--json] <tool> [args]\n");
printf(" Run one tool locally, then exit\n");
printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] "
"[--dir=<path>] [--skip-config]\n");
@@ -1610,9 +1637,10 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar
return NULL;
}
if (bootstrap.daemon_spawned) {
(void)fprintf(stderr, "hint: this command started a temporary CBM daemon. "
"`codebase-memory-mcp daemon start` keeps one warm and removes this "
"startup cost from every CLI command.\n");
/* Routine cold-start advice is informational. Default one-shot CLI
* output stays pipe-clean; `cli --verbose ...` opts into this detail. */
cbm_log_info("cli.daemon.spawned", "hint",
"codebase-memory-mcp daemon start keeps one warm");
}
char session_root[MAIN_PATH_CAP];
char allowed_root[MAIN_PATH_CAP];
+644
View File
@@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <yyjson/yyjson.h>
enum { SB_INITIAL_CAP = 1024 };
@@ -296,3 +297,646 @@ void cbm_tree_cell_bool(cbm_sb_t *sb, bool v, bool first) {
void cbm_tree_row_end(cbm_sb_t *sb) {
cbm_sb_append_n(sb, "\n", 1);
}
/* ── Response-local prefix directories ─────────────────────────── */
enum {
TREE_PREFIX_MIN_LEN = 12,
TREE_PREFIX_MIN_USES = 3,
TREE_PREFIX_MIN_SAVING = 64,
TREE_PREFIX_MIN_PERCENT = 15,
TREE_PREFIX_MAX_REFS = 16,
TREE_PREFIX_MAX_SEEDS = 1024,
TREE_PREFIX_MAX_CANDIDATES = 512,
TREE_PREFIX_MAX_CELLS = 8192,
TREE_PREFIX_MAX_LEN = 16 * 1024,
};
typedef struct {
char *text;
size_t len;
size_t uses;
size_t score;
int id;
bool active;
} tree_prefix_candidate_t;
static bool tree_column_is_string(const bool *string_cols, int col) {
return !string_cols || string_cols[col];
}
static bool tree_column_is_prefixable(const bool *string_cols, const bool *prefix_cols, int col) {
return prefix_cols && prefix_cols[col] && tree_column_is_string(string_cols, col);
}
static void tree_render_table_direct(cbm_sb_t *sb, const char *key, int nrows,
const char *const *cols, int ncols, const char *const *cells,
const bool *string_cols) {
cbm_tree_table_header(sb, key, nrows, cols, ncols);
for (int row = 0; row < nrows; row++) {
cbm_tree_row_begin(sb);
for (int col = 0; col < ncols; col++) {
const char *value = cells[(size_t)row * (size_t)ncols + (size_t)col];
if (tree_column_is_string(string_cols, col)) {
cbm_tree_cell_str(sb, value, col == 0);
} else {
if (col > 0) {
cbm_sb_append_n(sb, " ", 1);
}
cbm_sb_append(sb, value ? value : "null");
}
}
cbm_tree_row_end(sb);
}
}
static int tree_string_ptr_compare(const void *left, const void *right) {
const char *const *a = (const char *const *)left;
const char *const *b = (const char *const *)right;
return strcmp(*a, *b);
}
static int tree_prefix_score_compare(const void *left, const void *right) {
const tree_prefix_candidate_t *a = (const tree_prefix_candidate_t *)left;
const tree_prefix_candidate_t *b = (const tree_prefix_candidate_t *)right;
if (a->score != b->score) {
return a->score > b->score ? -1 : 1;
}
if (a->len != b->len) {
return a->len > b->len ? -1 : 1;
}
return strcmp(a->text, b->text);
}
static bool tree_prefix_candidate_add(tree_prefix_candidate_t *candidates, int *count,
const char *text, size_t len) {
if (len < TREE_PREFIX_MIN_LEN || len > TREE_PREFIX_MAX_LEN) {
return true;
}
for (int i = 0; i < *count; i++) {
if (candidates[i].len == len && memcmp(candidates[i].text, text, len) == 0) {
return true;
}
}
if (*count >= TREE_PREFIX_MAX_CANDIDATES) {
return true;
}
char *copy = (char *)malloc(len + 1);
if (!copy) {
return false;
}
memcpy(copy, text, len);
copy[len] = '\0';
candidates[*count].text = copy;
candidates[*count].len = len;
(*count)++;
return true;
}
static size_t tree_semantic_prefix_boundary(const char *text, size_t shared) {
size_t boundary = 0;
for (size_t i = 0; i < shared; i++) {
if (text[i] == '/' || text[i] == '\\' || text[i] == '.') {
boundary = i + 1;
} else if (text[i] == ':' && i + 1 < shared && text[i + 1] == ':') {
boundary = i + 2;
i++;
}
}
return boundary;
}
static bool tree_prefix_matches(const char *value, const tree_prefix_candidate_t *candidate) {
return value && !needs_quotes(value) && strlen(value) >= candidate->len &&
memcmp(value, candidate->text, candidate->len) == 0;
}
static int tree_prefix_choice(const char *value, const tree_prefix_candidate_t *candidates,
int selected_count, bool active_only) {
int choice = -1;
size_t longest = 0;
for (int i = 0; i < selected_count; i++) {
if ((active_only && !candidates[i].active) || !tree_prefix_matches(value, &candidates[i])) {
continue;
}
if (candidates[i].len > longest) {
choice = i;
longest = candidates[i].len;
}
}
return choice;
}
/* Unquoted @N+suffix is reserved for a directory reference whenever a table
* has a sibling <key>_refs table. Quote matching literals so a decoder can
* always distinguish data from an encoded reference. */
static bool tree_looks_like_prefix_ref(const char *value) {
if (!value || value[0] != '@' || !isdigit((unsigned char)value[1])) {
return false;
}
const char *p = value + 2;
while (isdigit((unsigned char)*p)) {
p++;
}
return *p == '+';
}
static void tree_append_unambiguous_literal(cbm_sb_t *sb, const char *value) {
if (tree_looks_like_prefix_ref(value)) {
append_quoted(sb, value);
} else {
append_value(sb, value);
}
}
static void tree_prefix_candidates_free(tree_prefix_candidate_t *candidates, int count) {
for (int i = 0; i < count; i++) {
free(candidates[i].text);
}
}
static void tree_table_rows_impl(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells, const bool *string_cols,
const bool *prefix_cols) {
if (!sb || !key || !cols || !cells || nrows < TREE_PREFIX_MIN_USES || ncols < 1 ||
!prefix_cols || (size_t)nrows > (size_t)-1 / (size_t)ncols) {
if (sb && key && cols && cells && nrows >= 0 && ncols >= 0) {
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
}
return;
}
size_t cell_count = (size_t)nrows * (size_t)ncols;
if (cell_count > TREE_PREFIX_MAX_CELLS) {
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
const char **seeds = (const char **)malloc(sizeof(*seeds) * TREE_PREFIX_MAX_SEEDS);
tree_prefix_candidate_t *candidates =
(tree_prefix_candidate_t *)calloc(TREE_PREFIX_MAX_CANDIDATES, sizeof(*candidates));
if (!seeds || !candidates) {
free(seeds);
free(candidates);
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
int seed_count = 0;
for (size_t i = 0; i < cell_count && seed_count < TREE_PREFIX_MAX_SEEDS; i++) {
int col = (int)(i % (size_t)ncols);
if (!tree_column_is_prefixable(string_cols, prefix_cols, col)) {
continue;
}
const char *value = cells[i];
if (value && strlen(value) >= TREE_PREFIX_MIN_LEN && !needs_quotes(value)) {
seeds[seed_count++] = value;
}
}
if (seed_count < TREE_PREFIX_MIN_USES) {
free(seeds);
free(candidates);
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
qsort(seeds, (size_t)seed_count, sizeof(*seeds), tree_string_ptr_compare);
int candidate_count = 0;
bool candidate_oom = false;
for (int i = 1; i < seed_count && !candidate_oom; i++) {
const char *left = seeds[i - 1];
const char *right = seeds[i];
size_t shared = 0;
while (left[shared] && right[shared] && left[shared] == right[shared] &&
shared < TREE_PREFIX_MAX_LEN) {
shared++;
}
if (left[shared] == '\0' && right[shared] == '\0') {
candidate_oom = !tree_prefix_candidate_add(candidates, &candidate_count, left, shared);
}
size_t boundary = tree_semantic_prefix_boundary(left, shared);
if (!candidate_oom && boundary >= TREE_PREFIX_MIN_LEN) {
candidate_oom =
!tree_prefix_candidate_add(candidates, &candidate_count, left, boundary);
}
}
free(seeds);
if (candidate_oom || candidate_count == 0) {
tree_prefix_candidates_free(candidates, candidate_count);
free(candidates);
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
/* Score candidates independently first. The final byte-for-byte render
* comparison below accounts for overlap and directory overhead exactly. */
for (int i = 0; i < candidate_count; i++) {
for (size_t cell = 0; cell < cell_count; cell++) {
int col = (int)(cell % (size_t)ncols);
if (tree_column_is_prefixable(string_cols, prefix_cols, col) &&
tree_prefix_matches(cells[cell], &candidates[i])) {
candidates[i].uses++;
}
}
if (candidates[i].uses >= TREE_PREFIX_MIN_USES && candidates[i].len > 5) {
size_t gross = candidates[i].uses * (candidates[i].len - 5);
size_t entry_cost = candidates[i].len + 12;
candidates[i].score = gross > entry_cost ? gross - entry_cost : 0;
}
}
qsort(candidates, (size_t)candidate_count, sizeof(*candidates), tree_prefix_score_compare);
int selected_count = 0;
while (selected_count < candidate_count && selected_count < TREE_PREFIX_MAX_REFS &&
candidates[selected_count].score > 0) {
selected_count++;
}
if (selected_count == 0) {
tree_prefix_candidates_free(candidates, candidate_count);
free(candidates);
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
int active_count = 0;
for (int i = 0; i < selected_count; i++) {
/* Recompute the exclusive longest-prefix assignment without another
* allocation; shorter overlapping candidates may become unused. */
size_t exclusive_uses = 0;
for (size_t cell = 0; cell < cell_count; cell++) {
int col = (int)(cell % (size_t)ncols);
if (tree_column_is_prefixable(string_cols, prefix_cols, col) &&
tree_prefix_choice(cells[cell], candidates, selected_count, false) == i) {
exclusive_uses++;
}
}
candidates[i].active = exclusive_uses >= TREE_PREFIX_MIN_USES;
if (candidates[i].active) {
candidates[i].id = active_count++;
}
}
if (active_count == 0) {
tree_prefix_candidates_free(candidates, candidate_count);
free(candidates);
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
size_t key_len = strlen(key);
char *refs_key = (char *)malloc(key_len + sizeof("_refs"));
char *rule_key = (char *)malloc(key_len + sizeof("_ref_rule"));
if (!refs_key || !rule_key) {
free(refs_key);
free(rule_key);
tree_prefix_candidates_free(candidates, candidate_count);
free(candidates);
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
return;
}
snprintf(refs_key, key_len + sizeof("_refs"), "%s_refs", key);
snprintf(rule_key, key_len + sizeof("_ref_rule"), "%s_ref_rule", key);
cbm_sb_t plain;
cbm_sb_init(&plain);
tree_render_table_direct(&plain, key, nrows, cols, ncols, cells, string_cols);
char *plain_text = cbm_sb_finish(&plain);
cbm_sb_t compact;
cbm_sb_init(&compact);
const char *ref_cols[] = {"id", "prefix"};
cbm_tree_table_header(&compact, refs_key, active_count, ref_cols, 2);
for (int i = 0; i < selected_count; i++) {
if (!candidates[i].active) {
continue;
}
cbm_tree_row_begin(&compact);
cbm_tree_cell_int(&compact, candidates[i].id, true);
cbm_sb_append_n(&compact, " ", 1);
/* Directories are declarations, never recursively expanded refs. Quote
* a ref-looking prefix anyway so a simple decoder cannot mistake it
* for another directory entry. */
tree_append_unambiguous_literal(&compact, candidates[i].text);
cbm_tree_row_end(&compact);
}
cbm_tree_scalar_str(&compact, rule_key, "@N+suffix=prefix+suffix");
cbm_tree_table_header(&compact, key, nrows, cols, ncols);
for (int row = 0; row < nrows; row++) {
cbm_tree_row_begin(&compact);
for (int col = 0; col < ncols; col++) {
if (col > 0) {
cbm_sb_append_n(&compact, " ", 1);
}
const char *value = cells[(size_t)row * (size_t)ncols + (size_t)col];
bool string_cell = tree_column_is_string(string_cols, col);
int choice = tree_column_is_prefixable(string_cols, prefix_cols, col)
? tree_prefix_choice(value, candidates, selected_count, true)
: -1;
if (choice >= 0) {
char ref[24];
snprintf(ref, sizeof(ref), "@%d+", candidates[choice].id);
cbm_sb_append(&compact, ref);
cbm_sb_append(&compact, value + candidates[choice].len);
} else if (string_cell) {
tree_append_unambiguous_literal(&compact, value);
} else {
cbm_sb_append(&compact, value ? value : "null");
}
}
cbm_tree_row_end(&compact);
}
char *compact_text = cbm_sb_finish(&compact);
bool use_compact = false;
if (plain_text && compact_text) {
size_t plain_len = strlen(plain_text);
size_t compact_len = strlen(compact_text);
size_t saving = plain_len > compact_len ? plain_len - compact_len : 0;
use_compact =
saving >= TREE_PREFIX_MIN_SAVING && saving * 100 >= plain_len * TREE_PREFIX_MIN_PERCENT;
}
if (use_compact) {
cbm_sb_append(sb, compact_text);
} else if (plain_text) {
cbm_sb_append(sb, plain_text);
} else {
tree_render_table_direct(sb, key, nrows, cols, ncols, cells, string_cols);
}
free(plain_text);
free(compact_text);
free(refs_key);
free(rule_key);
tree_prefix_candidates_free(candidates, candidate_count);
free(candidates);
}
void cbm_tree_table_rows_typed(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells, const bool *string_cols) {
tree_table_rows_impl(sb, key, nrows, cols, ncols, cells, string_cols, NULL);
}
void cbm_tree_table_rows(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells) {
tree_table_rows_impl(sb, key, nrows, cols, ncols, cells, NULL, NULL);
}
void cbm_tree_table_rows_profiled(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells, const bool *string_cols,
const bool *prefix_cols) {
tree_table_rows_impl(sb, key, nrows, cols, ncols, cells, string_cols, prefix_cols);
}
/* ── Generic JSON projection ────────────────────────────────────── */
enum { JSON_TREE_MAX_DEPTH = 32 };
static void json_tree_indent(cbm_sb_t *sb, int depth) {
for (int i = 0; i < depth; i++) {
cbm_sb_append_n(sb, " ", 2);
}
}
/* Source and Markdown lose substantial model utility when every newline is
* escaped into one long quoted scalar. Use the familiar YAML block markers:
* `|-` means no trailing newline, `|` means one, and `|+` preserves several.
* The content remains readable/copyable and valid UTF-8. */
static void json_tree_block_string(cbm_sb_t *sb, const char *text, int depth) {
size_t len = strlen(text);
size_t trailing_newlines = 0;
while (trailing_newlines < len && text[len - trailing_newlines - 1] == '\n') {
trailing_newlines++;
}
cbm_sb_append(sb, trailing_newlines == 0 ? "|-\n" : trailing_newlines == 1 ? "|\n" : "|+\n");
const char *line = text;
for (;;) {
const char *newline = strchr(line, '\n');
const char *end = newline ? newline : line + strlen(line);
json_tree_indent(sb, depth + 1);
for (const char *p = line; p < end; p++) {
unsigned char c = (unsigned char)*p;
if (c == '\t' || (c >= 0x20 && c != 0x7f && c < 0x80)) {
cbm_sb_append_n(sb, p, 1);
} else if (c >= 0x80) {
size_t sequence = utf8_sequence_length((const unsigned char *)p);
if (sequence && p + sequence <= end) {
cbm_sb_append_n(sb, p, sequence);
p += sequence - 1;
} else {
cbm_sb_append_n(sb, "\xEF\xBF\xBD", 3);
}
} else {
char escaped[8];
snprintf(escaped, sizeof(escaped), "\\u%04x", (unsigned)c);
cbm_sb_append(sb, escaped);
}
}
cbm_sb_append_n(sb, "\n", 1);
if (!newline) {
break;
}
line = newline + 1;
if (*line == '\0') {
break;
}
}
}
static void json_tree_scalar(cbm_sb_t *sb, yyjson_val *value) {
if (yyjson_is_str(value)) {
append_value(sb, yyjson_get_str(value));
} else if (yyjson_is_bool(value)) {
cbm_sb_append(sb, yyjson_get_bool(value) ? "true" : "false");
} else if (yyjson_is_null(value)) {
cbm_sb_append(sb, "null");
} else if (yyjson_is_num(value)) {
char *encoded = yyjson_val_write(value, 0, NULL);
cbm_sb_append(sb, encoded ? encoded : "null");
free(encoded);
} else {
char *encoded = yyjson_val_write(value, 0, NULL);
append_value(sb, encoded ? encoded : "null");
free(encoded);
}
}
static bool json_tree_array_is_object_table(yyjson_val *array, yyjson_val **first_out) {
if (!array || !first_out) {
return false;
}
size_t count = yyjson_arr_size(array);
if (count == 0) {
return false;
}
yyjson_val *first = yyjson_arr_get_first(array);
if (!first || !yyjson_is_obj(first) || yyjson_obj_size(first) == 0) {
return false;
}
size_t expected = yyjson_obj_size(first);
size_t index;
size_t maximum;
yyjson_val *item;
yyjson_arr_foreach(array, index, maximum, item) {
if (!yyjson_is_obj(item) || yyjson_obj_size(item) != expected) {
return false;
}
size_t key_index;
size_t key_maximum;
yyjson_val *key;
yyjson_val *unused;
yyjson_obj_foreach(first, key_index, key_maximum, key, unused) {
if (!yyjson_obj_get(item, yyjson_get_str(key))) {
return false;
}
}
}
*first_out = first;
return true;
}
static void json_tree_value(cbm_sb_t *sb, const char *key_name, yyjson_val *value, int depth);
static void json_tree_object_table(cbm_sb_t *sb, const char *key_name, yyjson_val *array,
yyjson_val *first, int depth) {
if (!sb || !key_name || !array || !first) {
return;
}
json_tree_indent(sb, depth);
cbm_sb_append(sb, key_name);
char count[48];
snprintf(count, sizeof(count), ": %zu (cols:", yyjson_arr_size(array));
cbm_sb_append(sb, count);
size_t key_index;
size_t key_maximum;
yyjson_val *column;
yyjson_val *unused;
yyjson_obj_foreach(first, key_index, key_maximum, column, unused) {
cbm_sb_append_n(sb, " ", 1);
cbm_sb_append(sb, yyjson_get_str(column));
}
cbm_sb_append_n(sb, ")\n", 2);
size_t row_index;
size_t row_maximum;
yyjson_val *row;
yyjson_arr_foreach(array, row_index, row_maximum, row) {
json_tree_indent(sb, depth + 1);
bool first_cell = true;
yyjson_obj_foreach(first, key_index, key_maximum, column, unused) {
if (!first_cell) {
cbm_sb_append_n(sb, " ", 1);
}
yyjson_val *cell = yyjson_obj_get(row, yyjson_get_str(column));
json_tree_scalar(sb, cell);
first_cell = false;
}
cbm_sb_append_n(sb, "\n", 1);
}
}
static void json_tree_array(cbm_sb_t *sb, const char *key_name, yyjson_val *array, int depth) {
yyjson_val *first = NULL;
if (json_tree_array_is_object_table(array, &first)) {
json_tree_object_table(sb, key_name, array, first, depth);
return;
}
json_tree_indent(sb, depth);
cbm_sb_append(sb, key_name);
char count[48];
snprintf(count, sizeof(count), ": %zu\n", yyjson_arr_size(array));
cbm_sb_append(sb, count);
size_t index;
size_t maximum;
yyjson_val *item;
yyjson_arr_foreach(array, index, maximum, item) {
if (yyjson_is_obj(item)) {
json_tree_indent(sb, depth + 1);
cbm_sb_append(sb, "-\n");
size_t field_index;
size_t field_maximum;
yyjson_val *field;
yyjson_val *field_value;
yyjson_obj_foreach(item, field_index, field_maximum, field, field_value) {
json_tree_value(sb, yyjson_get_str(field), field_value, depth + 2);
}
} else if (yyjson_is_arr(item)) {
json_tree_value(sb, "-", item, depth + 1);
} else {
json_tree_indent(sb, depth + 1);
cbm_sb_append(sb, "- ");
json_tree_scalar(sb, item);
cbm_sb_append_n(sb, "\n", 1);
}
}
}
static void json_tree_value(cbm_sb_t *sb, const char *key_name, yyjson_val *value, int depth) {
if (depth >= JSON_TREE_MAX_DEPTH) {
json_tree_indent(sb, depth);
cbm_sb_append(sb, key_name);
cbm_sb_append(sb, ": ");
char *encoded = yyjson_val_write(value, 0, NULL);
append_value(sb, encoded ? encoded : "null");
free(encoded);
cbm_sb_append_n(sb, "\n", 1);
return;
}
if (yyjson_is_obj(value)) {
json_tree_indent(sb, depth);
cbm_sb_append(sb, key_name);
cbm_sb_append(sb, ":\n");
size_t index;
size_t maximum;
yyjson_val *key;
yyjson_val *child;
yyjson_obj_foreach(value, index, maximum, key, child) {
json_tree_value(sb, yyjson_get_str(key), child, depth + 1);
}
} else if (yyjson_is_arr(value)) {
json_tree_array(sb, key_name, value, depth);
} else if (yyjson_is_str(value) &&
(strchr(yyjson_get_str(value), '\n') || strchr(yyjson_get_str(value), '\r'))) {
json_tree_indent(sb, depth);
cbm_sb_append(sb, key_name);
cbm_sb_append(sb, ": ");
json_tree_block_string(sb, yyjson_get_str(value), depth);
} else {
json_tree_indent(sb, depth);
cbm_sb_append(sb, key_name);
cbm_sb_append(sb, ": ");
json_tree_scalar(sb, value);
cbm_sb_append_n(sb, "\n", 1);
}
}
char *cbm_json_to_tree(const char *json) {
if (!json) {
return NULL;
}
yyjson_doc *document = yyjson_read(json, strlen(json), 0);
if (!document) {
return NULL;
}
yyjson_val *root = yyjson_doc_get_root(document);
cbm_sb_t sb;
cbm_sb_init(&sb);
if (yyjson_is_obj(root)) {
size_t index;
size_t maximum;
yyjson_val *key;
yyjson_val *value;
yyjson_obj_foreach(root, index, maximum, key, value) {
json_tree_value(&sb, yyjson_get_str(key), value, 0);
}
} else if (yyjson_is_arr(root)) {
json_tree_array(&sb, "items", root, 0);
} else {
cbm_sb_append(&sb, "value: ");
json_tree_scalar(&sb, root);
cbm_sb_append_n(&sb, "\n", 1);
}
yyjson_doc_free(document);
return cbm_sb_finish(&sb);
}
+23
View File
@@ -51,4 +51,27 @@ 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);
/* Emit a flat string table without abbreviating cell values. `cells` is
* row-major with nrows*ncols entries. */
void cbm_tree_table_rows(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells);
/* Mixed string/literal variant. Literal columns (numbers/booleans) retain
* their unquoted types. NULL string_cols means every column is a string. */
void cbm_tree_table_rows_typed(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells, const bool *string_cols);
/* Semantic prefix-directory variant. Only columns explicitly marked in
* prefix_cols may be factored, so labels, diagnostics, and arbitrary query
* strings are never rewritten. The directory activates only when it saves at
* least 15% and 64 bytes. */
void cbm_tree_table_rows_profiled(cbm_sb_t *sb, const char *key, int nrows, const char *const *cols,
int ncols, const char *const *cells, const bool *string_cols,
const bool *prefix_cols);
/* Convert an arbitrary JSON value to the compact tree representation used by
* lean MCP responses. Homogeneous object arrays become header-once tables;
* nested values remain lossless JSON cells. Returns a heap string. */
char *cbm_json_to_tree(const char *json);
#endif /* CBM_MCP_COMPACT_OUT_H */
+4340 -1467
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -75,5 +75,13 @@ bool cbm_search_code_windows_path_matches_prefilter(const char *path, const char
void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
const char *file_pattern, const char *tmpfile,
const char *filelist, const char *root_path);
#ifdef CBM_ENABLE_TEST_SEAMS
/* Filesystem PATH_MAX prevents a portable end-to-end fixture for multi-KiB
* stored paths. This seam drives the normal search result ownership,
* directory aggregation, and renderer pipeline with synthetic identities. */
char *cbm_mcp_render_search_rows_for_testing(const char *const *qualified_names,
const char *const *file_paths, int row_count,
bool json_format);
#endif
#endif
+74 -19
View File
@@ -8,6 +8,7 @@
// for ISO timestamp
#include <limits.h>
#include <stdint.h>
#include "foundation/constants.h"
#include "foundation/hash_table.h"
@@ -5393,10 +5394,14 @@ void cbm_store_search_free(cbm_search_output_t *out) {
static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_hop_t *visited,
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) {
int edge_type_count, int max_edges, cbm_edge_info_t **out_edges,
int *out_edge_count, bool *out_truncated) {
*out_edges = NULL;
*out_edge_count = 0;
*out_truncated = false;
if (max_edges == 0) {
return CBM_STORE_OK;
}
/* Visited-ID set via a per-connection TEMP table. The previous approach
* interpolated the ids into a fixed 4KB SQL string: past ~1000 visited
@@ -5408,22 +5413,37 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho
"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 */
store_set_error_sqlite(s, "bfs edge ids");
return CBM_STORE_ERR;
}
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;
store_set_error_sqlite(s, "bfs edge ids prepare");
return CBM_STORE_ERR;
}
sqlite3_bind_int64(ins, SKIP_ONE, start_id);
(void)sqlite3_step(ins);
if (sqlite3_step(ins) != SQLITE_DONE) {
store_set_error_sqlite(s, "bfs edge root id");
sqlite3_finalize(ins);
return CBM_STORE_ERR;
}
for (int i = 0; i < visited_count; i++) {
sqlite3_reset(ins);
sqlite3_bind_int64(ins, SKIP_ONE, visited[i].node.id);
(void)sqlite3_step(ins);
if (sqlite3_step(ins) != SQLITE_DONE) {
store_set_error_sqlite(s, "bfs edge visited id");
sqlite3_finalize(ins);
return CBM_STORE_ERR;
}
}
sqlite3_finalize(ins);
char limit_clause[ST_BUF_64] = "";
if (max_edges > 0) {
snprintf(limit_clause, sizeof(limit_clause), " LIMIT %d",
max_edges < INT_MAX ? max_edges + SKIP_ONE : max_edges);
}
char edge_sql[ST_SQL_BUF];
snprintf(edge_sql, sizeof(edge_sql),
"SELECT n1.name, n2.name, e.type, e.source_id, e.target_id, e.properties "
@@ -5432,15 +5452,15 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho
"JOIN nodes n2 ON n2.id = e.target_id "
"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)",
types_clause);
"AND e.type IN (%s) "
"ORDER BY e.source_id, e.target_id, e.type%s",
types_clause, limit_clause);
sqlite3_stmt *estmt = NULL;
int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL);
if (rc != SQLITE_OK) {
*out_edges = NULL;
*out_edge_count = 0;
return CBM_STORE_OK;
store_set_error_sqlite(s, "bfs edges prepare");
return CBM_STORE_ERR;
}
if (edge_type_count > 0) {
@@ -5479,6 +5499,15 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho
}
sqlite3_finalize(estmt);
if (max_edges > 0 && en > max_edges) {
en = max_edges;
safe_str_free(&edges[en].from_name);
safe_str_free(&edges[en].to_name);
safe_str_free(&edges[en].type);
safe_str_free(&edges[en].properties_json);
*out_truncated = true;
}
*out_edges = edges;
*out_edge_count = en;
return CBM_STORE_OK;
@@ -5533,8 +5562,11 @@ static int bfs_cte_row_limit_for_depth(int max_results, int max_depth) {
static int 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,
bool trail, cbm_traverse_result_t *out) {
bool trail, int max_edges, cbm_traverse_result_t *out) {
memset(out, 0, sizeof(*out));
if (max_results < 0) {
max_results = 0;
}
cbm_node_t root = {0};
int rc = cbm_store_find_node_by_id(s, start_id, &root);
@@ -5611,7 +5643,9 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
"GROUP BY n.id "
"ORDER BY hop, n.id "
"LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
/* One extra row makes max_results saturation observable. */
(long long)start_id, next_id, join_cond, types_clause, max_depth,
max_results < INT_MAX ? max_results + SKIP_ONE : max_results);
}
sqlite3_stmt *stmt = NULL;
@@ -5664,14 +5698,26 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
snprintf(limit_buf, sizeof(limit_buf), "%d", cte_row_limit);
cbm_log_warn("cypher.trail_truncated", "cte_rows", limit_buf, "result", "partial");
}
if (!trail && n > max_results) {
/* The extra probe row fired: drop it and report the ceiling honestly. */
n = max_results;
cbm_node_free_fields(&visited[n].node);
out->truncated = true;
}
out->visited = visited;
out->visited_count = n;
/* Collect edges between visited nodes (including root) */
if (n > 0) {
bfs_collect_edges(s, start_id, out->visited, n, types_clause, edge_types, edge_type_count,
&out->edges, &out->edge_count);
/* Edge properties are a secondary, potentially dense all-pairs lookup.
* Lean callers pass max_edges=0 and avoid it entirely; diagnostics callers
* pass an explicit ceiling and receive an honest saturation bit. */
if (n > 0 && max_edges != 0) {
rc = bfs_collect_edges(s, start_id, out->visited, n, types_clause, edge_types,
edge_type_count, max_edges, &out->edges, &out->edge_count,
&out->edges_truncated);
if (rc != CBM_STORE_OK) {
return rc;
}
} else {
out->edges = NULL;
out->edge_count = 0;
@@ -5683,14 +5729,23 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
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) {
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
false, out);
false, -1, out);
}
int cbm_store_bfs_trail(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) {
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
true, out);
true, -1, out);
}
int cbm_store_bfs_with_edge_limit(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, int max_edges, cbm_traverse_result_t *out) {
/* Bounded edge-data variant for lean callers: node traversal is the plain
* shortest-path BFS; max_edges=0 skips the all-pairs edge lookup entirely. */
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
false, max_edges, out);
}
/* Multi-source BFS: one recursive CTE anchored on ALL seeds (via a temp
+10 -1
View File
@@ -238,8 +238,10 @@ typedef struct {
int visited_count;
cbm_edge_info_t *edges;
int edge_count;
/* True when trail expansion hit its recursive-row safety budget. */
/* True when trail expansion hit its recursive-row safety budget (or, for
* the plain BFS, the max_results ceiling); counts are lower bounds. */
bool truncated;
bool edges_truncated; /* optional edge-data ceiling reached; node counts stay exact */
} cbm_traverse_result_t;
/* ── Schema introspection ───────────────────────────────────────── */
@@ -710,6 +712,13 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
int cbm_store_bfs_trail(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);
/* BFS with an explicit edge-data budget. max_edges=0 skips the secondary
* all-pairs edge lookup; max_edges>0 collects at most that many edges and
* raises out->edges_truncated on saturation. Node traversal is unchanged.
* This is intended for lean callers that need nodes but not edge properties. */
int cbm_store_bfs_with_edge_limit(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, int max_edges, 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
+2 -1
View File
@@ -1775,7 +1775,8 @@ static bool rpc_is_allowed_for_ui(const char *body, size_t body_len) {
const char *name_text = yyjson_is_str(name) ? yyjson_get_str(name) : NULL;
bool allowed =
method_text && strcmp(method_text, "tools/call") == 0 && name_text &&
(strcmp(name_text, "list_projects") == 0 || strcmp(name_text, "get_code_snippet") == 0);
(strcmp(name_text, "list_projects") == 0 || strcmp(name_text, "get_graph_schema") == 0 ||
strcmp(name_text, "get_code_snippet") == 0);
yyjson_doc_free(document);
return allowed;
}
+4 -5
View File
@@ -45,11 +45,10 @@ project_name() { printf '%s' "$1" | sed 's#^/##; s#[^A-Za-z0-9._-]#-#g'; }
# Run query_graph and print the first integer in the JSON response (or -1).
query_count() {
local proj="$1" cypher="$2" resp
resp="$("$BIN" cli query_graph "{\"project\":\"$proj\",\"query\":\"$cypher\"}" 2>/dev/null)"
printf '%s' "$resp" | "$PY" -c 'import sys,re
s=sys.stdin.read()
m=re.findall(r"-?\d+", s)
print(m[0] if m else -1)' 2>/dev/null || echo -1
resp="$("$BIN" cli query_graph "{\"project\":\"$proj\",\"query\":\"$cypher\",\"format\":\"json\"}" 2>/dev/null)"
printf '%s' "$resp" | "$PY" -c 'import json,sys
d=json.load(sys.stdin); rows=d.get("rows", [])
print(rows[0][0] if rows and rows[0] else -1)' 2>/dev/null || echo -1
}
assert_lang() {
+152
View File
@@ -21,6 +21,7 @@
#include <daemon/runtime.h>
#include <daemon/version_cohort.h>
#include <foundation/constants.h>
#include <foundation/log.h>
#include <foundation/platform.h>
#include <mcp/mcp.h>
#include <pipeline/pipeline.h>
@@ -103,6 +104,53 @@ TEST(cli_progress_sink_accepts_worker_json_logs) {
PASS();
}
TEST(cli_progress_sink_enables_lifecycle_info_then_restores_quiet_default) {
FILE *out = tmpfile();
ASSERT_NOT_NULL(out);
cbm_log_set_level(CBM_LOG_WARN);
cbm_progress_sink_init(out);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO);
cbm_log_info("pipeline.discover", "files", "3");
cbm_progress_sink_fini();
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN);
ASSERT_EQ(fseek(out, 0, SEEK_SET), 0);
char rendered[256] = {0};
size_t rendered_size = fread(rendered, 1, sizeof(rendered) - 1, out);
(void)fclose(out);
ASSERT_TRUE(rendered_size > 0);
ASSERT_NOT_NULL(strstr(rendered, "Discovering files (3 found)"));
PASS();
}
TEST(cli_progress_sink_suppresses_noise_and_preserves_failures) {
FILE *out = tmpfile();
ASSERT_NOT_NULL(out);
cbm_progress_sink_init(out);
cbm_progress_sink_fn("level=info msg=parallel.extract.progress done=1 total=2");
cbm_progress_sink_fn("level=debug msg=worker.detail value=drop-debug");
cbm_progress_sink_fn(
"{\"level\":\"info\",\"event\":\"worker.detail\",\"value\":\"drop-info\"}");
cbm_progress_sink_fn("level=warn msg=worker.warning detail=keep-text");
cbm_progress_sink_fn(
"{\"level\":\"error\",\"event\":\"worker.failure\",\"detail\":\"keep-json\"}");
cbm_progress_sink_fini();
ASSERT_EQ(fseek(out, 0, SEEK_SET), 0);
char rendered[1024] = {0};
size_t rendered_size = fread(rendered, 1, sizeof(rendered) - 1, out);
(void)fclose(out);
ASSERT_TRUE(rendered_size > 0);
ASSERT_STR_EQ(rendered,
"\r Extracting: 1/2 files (50%)\n"
"level=warn msg=worker.warning detail=keep-text\n"
"{\"level\":\"error\",\"event\":\"worker.failure\",\"detail\":\"keep-json\"}\n");
PASS();
}
enum { CLI_PROGRESS_RACE_THREADS = 4, CLI_PROGRESS_RACE_ROUNDS = 32 };
typedef struct {
@@ -8810,6 +8858,57 @@ TEST(cli_hook_session_resolves_custom_named_index_by_root_path) {
PASS();
}
TEST(cli_hook_session_exhausts_project_registry_pages) {
enum { DECOY_DATABASES = 500 };
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-project-pages-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char cache[512];
char decoy_root[512];
char target_root[512];
char decoy_db[640];
char target_db[640];
snprintf(cache, sizeof(cache), "%s/cache", tmpdir);
snprintf(decoy_root, sizeof(decoy_root), "%s/decoy", tmpdir);
snprintf(target_root, sizeof(target_root), "%s/target", tmpdir);
snprintf(decoy_db, sizeof(decoy_db), "%s/decoy-000.db", cache);
snprintf(target_db, sizeof(target_db), "%s/target.db", cache);
test_mkdirp(cache);
test_mkdirp(decoy_root);
test_mkdirp(target_root);
cbm_store_t *store = cbm_store_open_path(decoy_db);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_upsert_project(store, "aaa-decoy", decoy_root), CBM_STORE_OK);
cbm_store_close(store);
for (int i = 1; i < DECOY_DATABASES; i++) {
char copy[640];
snprintf(copy, sizeof(copy), "%s/decoy-%03d.db", cache, i);
ASSERT_EQ(cbm_copy_file(decoy_db, copy), 0);
}
store = cbm_store_open_path(target_db);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_upsert_project(store, "zzz-custom-project", target_root), CBM_STORE_OK);
cbm_store_close(store);
char *saved_cache = save_test_env("CBM_CACHE_DIR");
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char input[1024];
snprintf(input, sizeof(input), "{\"hook_event_name\":\"SessionStart\",\"cwd\":\"%s\"}",
target_root);
char *output = cbm_hook_augment_lifecycle_json(input);
bool matched = output && strstr(output, "zzz-custom-project") && strstr(output, "is indexed");
free(output);
restore_test_env("CBM_CACHE_DIR", saved_cache);
test_rmdir_r(tmpdir);
if (!matched)
FAIL("SessionStart must exhaust list_projects pages before resolving root_path");
PASS();
}
TEST(cli_hook_session_sanitizes_untrusted_project_metadata) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-untrusted-project-XXXXXX");
@@ -10398,6 +10497,55 @@ TEST(cli_hook_augment_bash_pattern_extractor) {
PASS();
}
TEST(cli_hook_augment_coverage_requests_machine_json) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-coverage-json-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char repo[512];
char source[640];
char db_path[640];
snprintf(repo, sizeof(repo), "%s/repo", tmpdir);
snprintf(source, sizeof(source), "%s/src/partial.c", repo);
snprintf(db_path, sizeof(db_path), "%s/hook.db", tmpdir);
test_mkdirp(repo);
char source_dir[640];
snprintf(source_dir, sizeof(source_dir), "%s/src", repo);
test_mkdirp(source_dir);
write_test_file(source, "int partial(void) { return 1; }\n");
cbm_store_t *store = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_upsert_project(store, "repo", repo), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(store, "repo", "src/partial.c", "fixture", 1, 32),
CBM_STORE_OK);
const cbm_coverage_row_t row = {
.rel_path = "src/partial.c", .kind = "parse_partial", .detail = "12-14"};
ASSERT_EQ(cbm_store_coverage_replace(store, "repo", &row, 1), CBM_STORE_OK);
cbm_store_close(store);
char *saved_cache = save_test_env("CBM_CACHE_DIR");
cbm_setenv("CBM_CACHE_DIR", tmpdir, 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_mcp_server_set_project(srv, "repo");
char input[2048];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\","
"\"tool_input\":{\"file_path\":\"%s\"},\"cwd\":\"%s\"}",
source, repo);
char *output = cbm_hook_augment_process(srv, input);
bool matched = output && strstr(output, "PARTIALLY indexed") && strstr(output, "12-14");
free(output);
cbm_mcp_server_free(srv);
restore_test_env("CBM_CACHE_DIR", saved_cache);
test_rmdir_r(tmpdir);
if (!matched)
FAIL("Post-read hook must parse check_index_coverage's explicit JSON response");
PASS();
}
TEST(cli_hook_augment_lifecycle_output_contract) {
static const struct {
@@ -14018,6 +14166,8 @@ SUITE(cli) {
RUN_TEST(cli_raw_mcp_result_preserves_tool_error_status);
RUN_TEST(cli_maintenance_cancellation_forces_failure_status);
RUN_TEST(cli_progress_sink_accepts_worker_json_logs);
RUN_TEST(cli_progress_sink_enables_lifecycle_info_then_restores_quiet_default);
RUN_TEST(cli_progress_sink_suppresses_noise_and_preserves_failures);
RUN_TEST(cli_progress_sink_serializes_concurrent_callbacks);
RUN_TEST(cli_sha256_file_matches_known_vector);
RUN_TEST(cli_checksum_manifest_requires_exact_filename_and_accepts_star);
@@ -14243,6 +14393,7 @@ SUITE(cli) {
RUN_TEST(cli_augment_installs_session_context_and_subagent);
RUN_TEST(cli_augment_session_uses_workspace_roots);
RUN_TEST(cli_hook_session_resolves_custom_named_index_by_root_path);
RUN_TEST(cli_hook_session_exhausts_project_registry_pages);
RUN_TEST(cli_hook_session_sanitizes_untrusted_project_metadata);
RUN_TEST(cli_hook_ownership_requires_exact_command_identity);
RUN_TEST(cli_gemini_hook_upgrade_migrates_released_exact_commands);
@@ -14274,6 +14425,7 @@ SUITE(cli) {
RUN_TEST(cli_hook_augment_context_tracks_search_json_shape);
RUN_TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter);
RUN_TEST(cli_hook_augment_bash_pattern_extractor);
RUN_TEST(cli_hook_augment_coverage_requests_machine_json);
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);
+143
View File
@@ -810,6 +810,9 @@ TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match) {
/* The discriminator: C has 5 callees, so claiming it has none is a
* fabrication. Pre-fix this is exactly what the saturated path emitted. */
ASSERT_FALSE(saw_c);
/* Late filtering leaves fewer than max_rows output rows, but the earlier
* relationship materialization still saturated its candidate budget. */
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
@@ -1274,6 +1277,9 @@ TEST(cypher_exec_varlength_path_semantics_issue797) {
ASSERT_EQ(r4.row_count, 0);
ASSERT_NOT_NULL(r4.warning);
ASSERT_NOT_NULL(strstr(r4.warning, "clamped"));
/* The warning reports the policy clamp, but this shallow fixture has no
* candidate beyond the ceiling, so evaluation was still exhaustive. */
ASSERT_FALSE(r4.truncated);
cbm_cypher_result_free(&r4);
cbm_store_close(s);
@@ -1783,6 +1789,7 @@ TEST(cypher_exec_variable_length) {
ASSERT_EQ(rc, 0);
/* Should find: ValidateOrder (1 hop), SubmitOrder (2 hops), LogError (1 hop) */
ASSERT_GTE(r.row_count, 3);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
@@ -1866,6 +1873,7 @@ TEST(cypher_exec_var_length_explicit_bound_capped) {
ASSERT_TRUE(saw_n10); /* within the ceiling — proves the traversal really ran */
ASSERT_FALSE(saw_n11); /* clamped away */
ASSERT_FALSE(saw_n12);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
@@ -2786,6 +2794,7 @@ TEST(cypher_apply_limit) {
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 5", "lim", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 5);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
/* No LIMIT, max_rows=10 → capped at 10 */
@@ -2793,6 +2802,7 @@ TEST(cypher_apply_limit) {
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "lim", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 10);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
/* LIMIT above max_rows → explicit limit wins */
@@ -2800,12 +2810,142 @@ TEST(cypher_apply_limit) {
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 30", "lim", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 30);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* max_rows is an engine output budget, not Cypher query semantics. Callers
* need to distinguish a complete short result from one cut off by that budget.
* An explicit LIMIT remains complete because it is part of the query itself. */
TEST(cypher_result_reports_max_rows_saturation) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "sat", "/tmp/sat");
for (int i = 0; i < 7; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "func%d", i);
snprintf(qn, sizeof(qn), "sat.func%d", i);
cbm_node_t n = {.project = "sat",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "test.c"};
cbm_store_upsert_node(s, &n);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "sat", 3, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "sat", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 7);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 3", "sat", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
/* RETURN * historically keeps max_rows as its output cap even when a
* larger Cypher LIMIT is present; preserve that behavior and report it. */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN * LIMIT 5", "sat", 3, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* An unlabeled scan has a max_rows*10 candidate budget. Aggregation can hide
* that loss behind one output row, so row_count alone cannot report it. */
TEST(cypher_result_reports_scan_saturation_before_aggregation) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "scan_sat", "/tmp/scan_sat");
for (int i = 0; i < 11; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "node%d", i);
snprintf(qn, sizeof(qn), "scan_sat.node%d", i);
cbm_node_t n = {
.project = "scan_sat", .label = "Function", .name = name, .qualified_name = qn};
cbm_store_upsert_node(s, &n);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN count(*) AS n", "scan_sat", 1, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "10");
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (n) RETURN count(*) AS n", "scan_sat", 2, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "11");
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Variable-length traversal historically materializes at most 100 visited
* nodes. Probe one extra candidate so an exact 100 is distinguishable from a
* graph with additional reachable nodes, while preserving the 100-row output. */
TEST(cypher_result_reports_variable_length_candidate_saturation) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "bfs_sat", "/tmp/bfs_sat");
cbm_node_t root = {.project = "bfs_sat",
.label = "Function",
.name = "root",
.qualified_name = "bfs_sat.root"};
int64_t root_id = cbm_store_upsert_node(s, &root);
for (int i = 0; i < 101; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "target%03d", i);
snprintf(qn, sizeof(qn), "bfs_sat.target%03d", i);
cbm_node_t target = {
.project = "bfs_sat", .label = "Var", .name = name, .qualified_name = qn};
int64_t target_id = cbm_store_upsert_node(s, &target);
cbm_edge_t edge = {
.project = "bfs_sat", .source_id = root_id, .target_id = target_id, .type = "CALLS"};
cbm_store_insert_edge(s, &edge);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function {name: \"root\"})-[:CALLS*1..2]->(b:Var) "
"RETURN b.name",
"bfs_sat", 1000, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 100);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 1: SIMPLE OPERATORS
* ══════════════════════════════════════════════════════════════════ */
@@ -4360,6 +4500,9 @@ SUITE(cypher) {
RUN_TEST(cypher_edge_filter_regex);
RUN_TEST(cypher_edge_builtin_type_filter);
RUN_TEST(cypher_apply_limit);
RUN_TEST(cypher_result_reports_max_rows_saturation);
RUN_TEST(cypher_result_reports_scan_saturation_before_aggregation);
RUN_TEST(cypher_result_reports_variable_length_candidate_saturation);
/* Phase 1: Simple operators */
RUN_TEST(cypher_lex_neq_operators);
RUN_TEST(cypher_lex_ends_keyword);
+3
View File
@@ -2079,6 +2079,8 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) {
bool second_interrupted = false;
bool exited = false;
atomic_store_explicit(&runtime_activation_shutdown_log_seen, false, memory_order_release);
CBMLogLevel previous_log_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_sink(runtime_test_activation_shutdown_sink);
if (started) {
@@ -2095,6 +2097,7 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) {
second_interrupted = !cbm_daemon_runtime_client_heartbeat(second, RUNTIME_TEST_TIMEOUT_MS);
}
cbm_log_set_sink(NULL);
cbm_log_set_level(previous_log_level);
if (first) {
(void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS);
first = NULL;
+18 -11
View File
@@ -1172,19 +1172,26 @@ TEST(ui_server_mutations_require_json_content_type) {
TEST(ui_server_rpc_allows_only_ui_read_tools) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}";
char req[1024];
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n\r\n%s",
(int)strlen(body), body);
char resp[8192];
int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
int n = 0;
static const char *allowed_tools[] = {"list_projects", "get_graph_schema", "get_code_snippet"};
for (size_t i = 0; i < sizeof(allowed_tools) / sizeof(allowed_tools[0]); i++) {
char body[512];
snprintf(body, sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"%s\",\"arguments\":{}}}",
allowed_tools[i]);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
}
static const char *blocked_tools[] = {"delete_project", "manage_adr", "ingest_traces",
"index_repository"};
+13 -7
View File
@@ -1568,26 +1568,29 @@ TEST(tool_qg_two_hop) {
TEST(tool_qg_max_rows) {
double ms;
/* Query without max_rows — gets many results */
/* Query without max_rows — its explicit Cypher LIMIT is the visible cap. */
char *r1 = call_tool_timed("query_graph", &ms,
"{\"project\":\"%s\","
"\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 100\"}",
g_project);
TOOL_OK(r1, ms);
int total_unlimited = count_in_response(r1, "total");
int returned_unlimited = count_in_response(r1, "returned");
free(r1);
/* Same query without LIMIT but with max_rows=3 — must cap results */
/* max_rows caps presentation, not evaluation: returned is bounded while
* total remains the exact full materialized count. */
char *r2 = call_tool_timed("query_graph", &ms,
"{\"project\":\"%s\","
"\"query\":\"MATCH (n:Function) RETURN n.name\","
"\"max_rows\":3}",
g_project);
TOOL_OK(r2, ms);
int returned_limited = count_in_response(r2, "returned");
int total_limited = count_in_response(r2, "total");
ASSERT_LTE(total_limited, 3);
/* Without max_rows should have more than with */
ASSERT_GT(total_unlimited, total_limited);
ASSERT_EQ(returned_limited, 3);
ASSERT_GT(total_limited, returned_limited);
ASSERT_GT(returned_unlimited, returned_limited);
ASSERT_NOT_NULL(strstr(r2, "page_limit"));
free(r2);
PASS();
}
@@ -2262,8 +2265,11 @@ TEST(tool_qg_max_rows_1) {
"\"max_rows\":1}",
g_project);
TOOL_OK(r, ms);
int returned = count_in_response(r, "returned");
int total = count_in_response(r, "total");
ASSERT_EQ(total, 1);
ASSERT_EQ(returned, 1);
ASSERT_GT(total, returned);
ASSERT_NOT_NULL(strstr(r, "page_limit"));
free(r);
PASS();
}
+4 -3
View File
@@ -392,7 +392,7 @@ TEST(index_parse_partial_reported) {
ASSERT_TRUE(marked);
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *qresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(qresp);
ASSERT_NOT_NULL(strstr(qresp, "split.c"));
@@ -516,7 +516,7 @@ TEST(index_parse_partial_clears_on_fix) {
/* Flagged after the first (full) index. */
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *cov1 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(cov1);
ASSERT_NOT_NULL(strstr(cov1, "flaky.c"));
@@ -635,7 +635,7 @@ TEST(index_not_indexed_by_design_reported) {
/* index_status carries the persisted by-design section. */
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *sresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(sresp);
ASSERT_NOT_NULL(strstr(sresp, "not_indexed"));
@@ -663,6 +663,7 @@ TEST(index_not_indexed_by_design_reported) {
char *resp2 = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs);
ASSERT_NOT_NULL(resp2);
free(resp2);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *sresp2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(sresp2);
ASSERT_NOT_NULL(strstr(sresp2, "secret.py"));
+28 -13
View File
@@ -55,15 +55,29 @@ static const char *capture_end(void) {
}
TEST(log_level_default) {
/* Default level should be INFO */
/* Routine informational output is opt-in; libraries start quiet. */
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN);
PASS();
}
TEST(log_level_env_keeps_info_and_debug_opt_in) {
cbm_setenv("CBM_LOG_LEVEL", "info", 1);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO);
cbm_setenv("CBM_LOG_LEVEL", "debug", 1);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_DEBUG);
cbm_unsetenv("CBM_LOG_LEVEL");
cbm_log_set_level(CBM_LOG_WARN);
PASS();
}
TEST(log_level_set) {
cbm_log_set_level(CBM_LOG_WARN);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN);
cbm_log_set_level(CBM_LOG_INFO); /* restore */
cbm_log_set_level(CBM_LOG_WARN); /* restore */
PASS();
}
@@ -72,7 +86,7 @@ TEST(log_info_output) {
capture_start();
cbm_log_info("test.msg", "key1", "val1", "key2", "val2");
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "level=info"));
ASSERT(cbm_str_contains_raw(output, "msg=test.msg"));
@@ -86,7 +100,7 @@ TEST(log_filtered_by_level) {
capture_start();
cbm_log_info("should.not.appear");
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
/* Should be empty — info is below warn threshold */
ASSERT_EQ(strlen(output), 0);
@@ -98,7 +112,7 @@ TEST(log_error_output) {
capture_start();
cbm_log_error("critical.fail", "err", "OOM");
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "level=error"));
ASSERT(cbm_str_contains_raw(output, "msg=critical.fail"));
@@ -111,7 +125,7 @@ TEST(log_int_helper) {
capture_start();
cbm_log_int(CBM_LOG_INFO, "pass.timing", "elapsed_ms", 42);
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "elapsed_ms=42"));
PASS();
@@ -124,7 +138,7 @@ TEST(log_json_output) {
cbm_log_info("test.msg", "key1", "val1", "key2", "line\nbreak");
const char *output = capture_end();
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "\"level\":\"info\""));
ASSERT(cbm_str_contains_raw(output, "\"event\":\"test.msg\""));
@@ -139,7 +153,7 @@ TEST(log_text_sanitizes_control_chars) {
capture_start();
cbm_log_info("test\nmsg", "key", "line\r\nbreak\tvalue");
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "msg=test_msg"));
ASSERT(cbm_str_contains_raw(output, "key=line__break_value"));
@@ -157,7 +171,7 @@ TEST(log_sink_tee_keeps_stderr) {
cbm_log_info("tee.msg", "key", "val");
const char *output = capture_end();
cbm_log_set_sink(NULL);
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "msg=tee.msg"));
ASSERT(cbm_str_contains_raw(sink_buf, "msg=tee.msg"));
@@ -171,7 +185,7 @@ TEST(log_operational_helpers) {
cbm_log_mcp_request("tools/call", "search_graph", false, 1250);
cbm_log_http_request("graph_ui", "GET", "/api/layout", 200, 7, 0, 42);
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(CBM_LOG_WARN);
ASSERT(cbm_str_contains_raw(output, "msg=mcp.request"));
ASSERT(cbm_str_contains_raw(output, "protocol=jsonrpc"));
@@ -235,7 +249,7 @@ TEST(log_level_from_env_textual) {
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO);
cbm_unsetenv("CBM_LOG_LEVEL");
cbm_log_set_level(CBM_LOG_INFO); /* restore */
cbm_log_set_level(CBM_LOG_WARN); /* restore */
PASS();
}
@@ -260,7 +274,7 @@ TEST(log_level_from_env_numeric) {
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO);
cbm_unsetenv("CBM_LOG_LEVEL");
cbm_log_set_level(CBM_LOG_INFO); /* restore */
cbm_log_set_level(CBM_LOG_WARN); /* restore */
PASS();
}
@@ -279,12 +293,13 @@ TEST(log_level_from_env_invalid_ignored) {
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN);
cbm_log_set_level(CBM_LOG_INFO); /* restore */
cbm_log_set_level(CBM_LOG_WARN); /* restore */
PASS();
}
SUITE(log) {
RUN_TEST(log_level_default);
RUN_TEST(log_level_env_keeps_info_and_debug_opt_in);
RUN_TEST(log_level_set);
RUN_TEST(log_info_output);
RUN_TEST(log_filtered_by_level);
+3366 -40
View File
File diff suppressed because it is too large. Load diff
+18 -5
View File
@@ -6,7 +6,8 @@ Guards the PR #1139 daemon-control surface at the product level:
* ``daemon start`` launches a PERMANENT daemon: it reports a pid, and the
daemon survives its clients (a one-shot ``cli`` command recycles it without
printing the cold-start hint, and the daemon is still active afterwards).
* A cold one-shot ``cli`` command (no daemon) prints the startup-tax hint.
* A cold one-shot ``cli`` command is quiet by default; ``cli --verbose`` opts
into the startup-tax hint.
* ``daemon stop`` on an idle daemon stops it; a second ``stop`` is idempotent.
Every path carries a kill-by-pid backstop so a stuck daemon can never hang the
@@ -68,11 +69,23 @@ def main():
cold = run_cli(binary, cache, ["cli", "list_projects", "{}"])
cold_text = output_text(cold)
if cold.returncode != 0 or "daemon start" not in cold_text:
print("RED: a cold one-shot cli command should succeed and hint at "
"`daemon start`:\n%s" % cold_text[:400])
if cold.returncode != 0 or "daemon start" in cold_text:
print("RED: a cold one-shot cli command should succeed without default "
"startup chatter:\n%s" % cold_text[:400])
return 1
print("PASS: cold cli one-shot succeeded and printed the startup-tax hint")
print("PASS: cold cli one-shot succeeded quietly")
# Use an isolated rendezvous namespace to guarantee a cold verbose call.
verbose_cache = os.path.join(work, "cache-verbose")
os.makedirs(verbose_cache, exist_ok=True)
verbose = run_cli(binary, verbose_cache,
["cli", "--verbose", "list_projects", "{}"])
verbose_text = output_text(verbose)
if verbose.returncode != 0 or "daemon start" not in verbose_text:
print("RED: `cli --verbose` should expose the cold-start hint:\n%s"
% verbose_text[:400])
return 1
print("PASS: verbose cold cli exposed the startup-tax hint")
start = run_cli(binary, cache, ["daemon", "start"])
start_text = output_text(start)
+3 -3
View File
@@ -332,9 +332,9 @@ def section_crash_recovery(binary, work):
"(`daemon status` kept reporting it)" % daemon_pid)
return False
cold = run_cli(binary, cache, ["cli", "list_projects", "{}"], timeout=90)
if cold.returncode != 0 or "daemon start" not in out_text(cold):
print("RED: a cold one-shot after the daemon crash should succeed with the "
"startup-tax hint:\n%s" % excerpt(out_text(cold)))
if cold.returncode != 0 or "daemon start" in out_text(cold):
print("RED: a cold one-shot after the daemon crash should succeed without "
"default startup chatter:\n%s" % excerpt(out_text(cold)))
return False
restart = run_cli(binary, cache, ["daemon", "start"], timeout=60)
second_pid = pid_from(out_text(restart))