mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
feat(mcp): make tool output lean and lossless
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
ceba42728a
commit
3909e5a0e1
33 files changed
+9310
-1641
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user