Merge pull request #1597 from DeusData/feat/lean-output-contract

feat: make CLI and MCP output lean by default
This commit is contained in:
Martin Vogel
2026-09-05 10:42:06 +02:00
committed by GitHub
47 changed files with 15261 additions and 2412 deletions
+13 -4
View File
@@ -629,7 +629,13 @@ 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. Pass `--quiet` to disable automatic terminal progress and ordinary diagnostics while retaining errors; it cannot be combined with `--progress` or outer `cli --verbose`. 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 the exact rendered table is at least 15% and 64 bytes smaller and a conservative model-neutral token-shape proxy also improves by at least 1%. 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. Both gates are deterministic; 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` pages ranked rows with `result_limit`/`result_offset` (`limit` remains a compatibility alias), raw rows with `raw_limit`/`raw_offset`, and directory summaries with `directory_limit`/`directory_offset`. Raw lines default to a UTF-8-safe match-centered preview; each row reports `content_start_byte`, returned/total byte counts, match byte bounds when known, and a content continuation offset. Pass `raw_content_offset` to page the original line without moving the raw-row cursor. `match_limit` and `source_max_lines` bound per-result details, with exact omission metadata. `detect_changes` pages changed files, impacted symbols, and module summaries independently; prefer its snapshot-bound `*_cursor` continuations, which reject changed commits, worktree bytes, graph generation, or semantic arguments instead of silently skipping or duplicating rows.
Every response is standard UTF-8. Identifiers, paths, and raw search previews preserve POSIX byte-string identities: a preserved value containing malformed UTF-8 is emitted reversibly as `@bytes:<lowercase hex of every original byte>`. A valid preserved value that literally begins with the reserved `@bytes:` or `@utf8:` prefix is emitted as `@utf8:<original value>`, so decoding is unambiguous: strip one `@utf8:` prefix for literal UTF-8, or hex-decode one `@bytes:` prefix for original bytes. Ordinary valid UTF-8 is unchanged and pays no output-token overhead. To keep code readable, source bodies replace malformed UTF-8 with U+FFFD; use the pageable raw search preview when byte-exact source inspection is required.
Use `cli <tool> --help` to see the flags generated from that tool's input schema:
@@ -644,7 +650,10 @@ 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'
# Suppress automatic terminal progress and non-error diagnostics.
codebase-memory-mcp cli --quiet list_projects --format json
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.
@@ -664,7 +673,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
| Tool | Description |
|------|-------------|
| `search_graph` | Structured search by label, name pattern, file pattern, degree filters. Pagination via limit/offset. |
| `search_graph` | Structural, BM25, and semantic search. Page structural rows with `offset`/`limit` and ranked semantic rows independently with `semantic_offset`/`semantic_limit`. |
| `trace_path` | BFS traversal — who calls a function and what it calls (alias: `trace_call_path`). Depth 1-5. |
| `detect_changes` | Map git diff to affected symbols + blast radius with risk classification. |
| `query_graph` | Execute Cypher-like graph queries (read-only). |
@@ -730,7 +739,7 @@ codebase-memory-mcp config reset auto_index # reset to default
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. One account can use only one canonical cache root at a time; close active CBM sessions/commands before switching it. |
| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable the shared daemon's periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. Exact paths are logged by `diagnostics.start`. |
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Thin-frontend messages go to that session's stderr; detached daemon events go to `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Stdout is reserved for MCP JSON-RPC. |
| `CBM_LOG_LEVEL` | role-aware | Set the minimum log level. Thin MCP/CLI/hook frontends default to `warn`; the detached daemon and its supervised index workers default to `info` so lifecycle and liveness records remain available. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4`. A physical worker retains INFO liveness records even under a stricter override because its private log drives the supervisor's no-progress timeout. Frontend messages go to that session's stderr; detached daemon events go to `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Stdout is reserved for MCP JSON-RPC. |
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. |
| `CBM_MEM_BUDGET_MB` | *(detected)* | Override the in-memory graph budget with an explicit cap in MiB, taking precedence over the `ram_fraction × total_RAM` default. Useful on bare-metal hosts without a cgroup limit, or to pin a budget *below* the cgroup limit so headroom is left for sibling processes. Must be a positive integer; it is clamped to detected total RAM (logged as `mem.budget.clamped`), and non-numeric or non-positive values are ignored with a warning (`mem.budget.env.invalid`). |
| `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0–1; set `0` to disable. Invalid values are ignored with a warning. |
+1 -1
View File
@@ -149,7 +149,7 @@ These environment variables affect runtime behavior:
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. |
| `CBM_DIAGNOSTICS` | `false` | Enable periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. The daemon records the randomized paths in the `diagnostics.start` discovery record (a single JSON line) in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`; that one record is emitted even when `CBM_LOG_LEVEL` suppresses ordinary logging, so the paths always remain discoverable. |
| `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. |
| `CBM_LOG_LEVEL` | `info` | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin-frontend messages use that session's stderr; detached daemon events use `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. |
| `CBM_LOG_LEVEL` | role-aware | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin MCP/CLI/hook frontends default to `warn`; the detached daemon and supervised index workers default to `info`. Physical workers retain INFO liveness records because their private logs drive the supervisor's no-progress timeout. Frontend messages use that session's stderr; detached daemon events use `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. |
| `CBM_RUNTIME_DIR` | `%LOCALAPPDATA%` (Windows), `/private/tmp` (macOS), `/tmp` (other) | Parent directory for the daemon/CLI rendezvous directory, which CBM creates inside it as `cbm-daemon-<uid>` (`cbm-daemon-<key>` on Windows). Set it when the default ancestry cannot pass the private-directory check — see below. `CBM_CACHE_DIR` does **not** move the rendezvous. |
| `CBM_WORKERS` | auto-detected | Override the indexing worker count. |
@@ -68,6 +68,12 @@ describe("NodeDetailPanel code preview + deep-link", () => {
/* …but was NOT parsed into a real <script> element, and did not execute. */
expect(container.querySelector("script")).toBeNull();
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
expect(callToolMock).toHaveBeenCalledWith("get_code_snippet", {
qualified_name: "app::render",
project: "demo",
format: "json",
source_mode: "full",
});
});
it("builds an https GitHub deep-link with URL-encoded path segments", () => {
@@ -76,6 +76,8 @@ export function NodeDetailPanel({
const res = await callTool<SnippetResult>("get_code_snippet", {
qualified_name: node.qualified_name,
project,
format: "json",
source_mode: "full",
});
setCode(res.source ?? "(source not available)");
} catch (e) {
+5 -2
View File
@@ -12,7 +12,7 @@ function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response
if (overridden) return overridden;
if (url === "/rpc") {
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
result: { content: [{ text: JSON.stringify({ projects: [], has_more: false }) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/ui-config")) {
@@ -221,8 +221,11 @@ describe("StatsTab index modal", () => {
root_path: "/repo",
indexed_at: "2026-01-01T00:00:00Z",
}],
has_more: false,
}
: { node_labels: [], edge_types: [], total_nodes: 0, total_edges: 0 };
: {
node_labels: [], edge_types: [], total_nodes: 0, total_edges: 0, has_more: false,
};
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify(result) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
+86
View File
@@ -0,0 +1,86 @@
/* @vitest-environment jsdom */
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProjects } from "./useProjects";
const callToolMock = vi.fn();
vi.mock("../api/rpc", () => ({
callTool: (...args: unknown[]) => callToolMock(...args),
}));
describe("useProjects machine-readable pagination", () => {
beforeEach(() => callToolMock.mockReset());
it("requests JSON and merges every project and schema page", async () => {
callToolMock.mockImplementation(async (name: string, args: Record<string, unknown>) => {
if (name === "list_projects") {
if (args.offset === 0) {
return {
projects: [{ name: "alpha", root_path: "/alpha", indexed_at: "now" }],
has_more: true,
next_offset: 1,
};
}
return {
projects: [{ name: "beta", root_path: "/beta", indexed_at: "now" }],
has_more: false,
};
}
if (name === "get_graph_schema" && args.project === "alpha") {
if (args.offset === 0) {
return {
node_labels: [{ label: "Function", count: 3 }],
edge_types: [],
total_nodes: 3,
total_edges: 2,
has_more: true,
next_offset: 1,
};
}
return {
node_labels: [],
edge_types: [{ type: "CALLS", count: 2 }],
total_nodes: 3,
total_edges: 2,
has_more: false,
};
}
return {
node_labels: [{ label: "Class", count: 1 }],
edge_types: [],
total_nodes: 1,
total_edges: 0,
has_more: false,
};
});
const { result } = renderHook(() => useProjects());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.projects).toHaveLength(2);
expect(result.current.projects[0].schema?.node_labels).toEqual([
{ label: "Function", count: 3 },
]);
expect(result.current.projects[0].schema?.edge_types).toEqual([
{ type: "CALLS", count: 2 },
]);
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
format: "json",
detail: "stats",
limit: 500,
offset: 0,
});
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
format: "json",
detail: "stats",
limit: 500,
offset: 1,
});
expect(callToolMock).toHaveBeenCalledWith("get_graph_schema", {
project: "alpha",
format: "json",
limit: 500,
offset: 1,
});
});
});
+66 -5
View File
@@ -7,6 +7,70 @@ interface ProjectInfo {
schema: SchemaInfo | null;
}
interface ProjectPage {
projects?: Project[];
has_more?: boolean;
next_offset?: number;
}
interface SchemaPage extends SchemaInfo {
has_more?: boolean;
next_offset?: number;
}
const PAGE_LIMIT = 500;
function nextPageOffset(page: { has_more?: boolean; next_offset?: number }, offset: number) {
if (typeof page.has_more !== "boolean") {
throw new Error("Invalid pagination response");
}
if (!page.has_more) return null;
if (!Number.isInteger(page.next_offset) || page.next_offset! <= offset) {
throw new Error("Invalid pagination response");
}
return page.next_offset!;
}
async function fetchAllProjects(): Promise<Project[]> {
const projects: Project[] = [];
let offset = 0;
for (;;) {
const page = await callTool<ProjectPage>("list_projects", {
format: "json",
detail: "stats",
limit: PAGE_LIMIT,
offset,
});
projects.push(...(page.projects ?? []));
const next = nextPageOffset(page, offset);
if (next === null) return projects;
offset = next;
}
}
async function fetchFullSchema(project: string): Promise<SchemaInfo> {
const nodeLabels: SchemaInfo["node_labels"] = [];
const edgeTypes: SchemaInfo["edge_types"] = [];
let firstPage: SchemaPage | null = null;
let offset = 0;
for (;;) {
const page = await callTool<SchemaPage>("get_graph_schema", {
project,
format: "json",
limit: PAGE_LIMIT,
offset,
});
firstPage ??= page;
nodeLabels.push(...(page.node_labels ?? []));
edgeTypes.push(...(page.edge_types ?? []));
const next = nextPageOffset(page, offset);
if (next === null) {
return { ...firstPage, node_labels: nodeLabels, edge_types: edgeTypes };
}
offset = next;
}
}
interface UseProjectsResult {
projects: ProjectInfo[];
loading: boolean;
@@ -23,16 +87,13 @@ export function useProjects(): UseProjectsResult {
setLoading(true);
setError(null);
try {
const result = await callTool<{ projects: Project[] }>("list_projects");
const list = result.projects ?? [];
const list = await fetchAllProjects();
/* Fetch schema for each project */
const infos: ProjectInfo[] = await Promise.all(
list.map(async (p) => {
try {
const schema = await callTool<SchemaInfo>("get_graph_schema", {
project: p.name,
});
const schema = await fetchFullSchema(p.name);
return { project: p, schema };
} catch {
return { project: p, schema: null };
+8 -6
View File
@@ -407,7 +407,7 @@ DUP_CHECKED=0
for TOOL_ARGS in "search_graph --project $PROJECT --name-pattern compute" \
"search_code --project $PROJECT --query compute" \
"get_architecture --project $PROJECT" \
"index_status --project $PROJECT"; do
"index_status --project $PROJECT --format json"; do
# shellcheck disable=SC2086
ENVELOPE=$("$BINARY" cli $TOOL_ARGS --json 2>/dev/null || true)
[ -z "$ENVELOPE" ] && continue
@@ -575,7 +575,7 @@ fi
echo "OK: trace_path found $CALLERS caller(s) for 'compute'"
# 3c: get_graph_schema — verify labels exist
if ! SCHEMA=$(cli get_graph_schema --project "$PROJECT"); then
if ! SCHEMA=$(cli get_graph_schema --project "$PROJECT" --format json --limit 500); then
echo "FAIL: get_graph_schema (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
LABELS=$(echo "$SCHEMA" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('node_labels',[])))" 2>/dev/null || echo "0")
@@ -647,8 +647,10 @@ cyp_first_cell() {
# $1 = query; echoes rows[0][0] (or empty). Flag form passes the query as ONE
# argv token, so string-literal args (e.g. replace(f.name,"a","A")) and Cypher
# metacharacters {}|=~<>" need no JSON escaping.
cli query_graph --project "$PROJECT" --query "$1" |
sed -n '/^rows: /{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
cli query_graph --project "$PROJECT" --query "$1" --format json |
python3 -c 'import json,sys
d=json.load(sys.stdin); rows=d.get("rows", [])
print(rows[0][0] if rows and rows[0] else "")'
}
# labels(n) → JSON list like ["Function"]
@@ -840,7 +842,7 @@ fi
# B4: STDIN + --json is the generated-client transport. It must return the
# complete MCP result envelope and must NOT emit a deprecation warning.
IM_STDIN=$(printf '%s' "{\"project\":\"$PROJECT\"}" | "$BINARY" cli --json get_graph_schema 2>"$CLI_STDERR")
IM_STDIN=$(printf '%s' "{\"project\":\"$PROJECT\",\"format\":\"json\"}" | "$BINARY" cli --json get_graph_schema 2>"$CLI_STDERR")
if ! printf '%s' "$IM_STDIN" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); c=d.get('content'); p=json.loads(c[0].get('text','')) if isinstance(c,list) and c and c[0].get('type') == 'text' else None; sys.exit(0 if d.get('isError') is not True and isinstance(p,dict) and isinstance(p.get('node_labels'),list) else 1)" 2>/dev/null; then
echo "FAIL B4: compact stdin + --json did not return a successful get_graph_schema MCP payload"; echo "$IM_STDIN" | head -c 300; cat "$CLI_STDERR"; exit 1
fi
@@ -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
+43 -1
View File
@@ -322,6 +322,10 @@ void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent)
g_cli_activation_runtime_parent_for_test = runtime_parent;
}
const char *cbm_cli_activation_runtime_parent_for_test(void) {
return g_cli_activation_runtime_parent_for_test;
}
static const char *cli_activation_action_text(cbm_daemon_runtime_activation_action_t action) {
switch (action) {
case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL:
@@ -13235,6 +13239,18 @@ char *cbm_cli_build_args_json(const char *tool_name, int argc, char **argv, char
return result;
}
static void cli_print_schema_value(const yyjson_val *value) {
if (yyjson_is_str(value)) {
fputs(yyjson_get_str(value), stdout);
return;
}
char *json = yyjson_val_write(value, 0, NULL);
if (json) {
fputs(json, stdout);
free(json);
}
}
int cbm_cli_print_tool_help(const char *tool_name) {
const char *schema_str = cbm_mcp_tool_input_schema(tool_name);
if (!schema_str) {
@@ -13265,6 +13281,8 @@ int cbm_cli_print_tool_help(const char *tool_name) {
}
const char *type = "string";
const char *desc = "";
yyjson_val *choices = NULL;
yyjson_val *default_value = NULL;
if (yyjson_is_obj(pval)) {
yyjson_val *t = yyjson_obj_get(pval, "type");
if (t && yyjson_is_str(t)) {
@@ -13274,12 +13292,36 @@ int cbm_cli_print_tool_help(const char *tool_name) {
if (d && yyjson_is_str(d)) {
desc = yyjson_get_str(d);
}
choices = yyjson_obj_get(pval, "enum");
default_value = yyjson_obj_get(pval, "default");
}
char flag[CLI_BUF_256];
snprintf(flag, sizeof(flag), "%s", name);
cli_snake_to_kebab(flag);
bool req = cli_schema_required_has(required, name);
printf(" --%s <%s>%s", flag, type, req ? " [required]" : "");
printf(" --%s <", flag);
if (choices && yyjson_is_arr(choices) && yyjson_arr_size(choices) > 0) {
size_t idx;
size_t max;
yyjson_val *choice;
yyjson_arr_foreach(choices, idx, max, choice) {
if (idx > 0) {
fputc('|', stdout);
}
cli_print_schema_value(choice);
}
} else {
fputs(type, stdout);
}
fputc('>', stdout);
if (req) {
fputs(" [required]", stdout);
}
if (default_value) {
fputs(" [default: ", stdout);
cli_print_schema_value(default_value);
fputc(']', stdout);
}
if (desc[0]) {
printf(" %s", desc);
}
+1
View File
@@ -483,6 +483,7 @@ void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops);
* private runtime parent. NULL restores the platform default. This is not a
* command-line or environment override. */
void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent);
const char *cbm_cli_activation_runtime_parent_for_test(void);
/* ── Subcommands (wired from main.c) ─────────────────────────── */
+2 -2
View File
@@ -213,14 +213,14 @@ char *cbm_client_adapter_pi(const char *binary_path) {
if (!name || !name[0]) {
continue;
}
const char *title = cbm_mcp_tool_title(name);
const char *description = cbm_mcp_tool_description(name);
const char *schema = cbm_mcp_tool_input_schema(name);
sb_append(&sb, " pi.registerTool({\n");
sb_append(&sb, " name: ");
sb_append_js_string(&sb, name);
sb_append(&sb, ",\n label: ");
sb_append_js_string(&sb, title ? title : name);
/* Tools carry no separate title: the name is the MCP display fallback. */
sb_append_js_string(&sb, name);
sb_append(&sb, ",\n description: ");
sb_append_js_string(&sb, description ? description : "");
sb_append(&sb, ",\n parameters: ");
+132 -38
View File
@@ -27,6 +27,7 @@
#include <ctype.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -45,6 +46,7 @@
#define HA_MIN_TOKEN 4 /* skip short/noisy patterns before any work */
#define HA_MAX_TOKEN 96
#define HA_RESULT_LIMIT 5
#define HA_LIST_PAGE_LIMIT 500
#define HA_METADATA_CAP 192
#define HA_MAX_WALKUP 8 /* cwd may be a subdir of the indexed root */
#define HA_DEADLINE_MS 300 /* hard in-process budget (see also: the */
@@ -511,11 +513,45 @@ static char *ha_coverage_context(const char *envelope, const char *rel, bool *is
strcmp(status, "invalid_path") != 0) {
const char *kind = NULL;
const char *detail = NULL;
char *range_detail = NULL;
yyjson_val *coverage = yyjson_obj_get(item, "coverage");
yyjson_val *row = coverage && yyjson_is_arr(coverage) ? yyjson_arr_get(coverage, 0) : NULL;
if (row) {
kind = ha_obj_str(row, "kind");
detail = ha_obj_str(row, "detail");
yyjson_val *ranges = yyjson_obj_get(row, "ranges");
size_t range_count = ranges && yyjson_is_arr(ranges) ? yyjson_arr_size(ranges) : 0;
if ((!detail || !detail[0]) && range_count > 0 && range_count <= SIZE_MAX / 48U) {
range_detail = calloc(range_count * 48U + 1U, 1U);
if (range_detail) {
size_t used = 0;
size_t index;
size_t maximum;
yyjson_val *range;
yyjson_arr_foreach(ranges, index, maximum, range) {
yyjson_val *start_value = yyjson_obj_get(range, "start");
yyjson_val *end_value = yyjson_obj_get(range, "end");
if (!yyjson_is_int(start_value) || !yyjson_is_int(end_value)) {
continue;
}
long long start = (long long)yyjson_get_sint(start_value);
long long end = (long long)yyjson_get_sint(end_value);
int written =
start == end
? snprintf(range_detail + used, range_count * 48U + 1U - used,
"%s%lld", used ? "," : "", start)
: snprintf(range_detail + used, range_count * 48U + 1U - used,
"%s%lld-%lld", used ? "," : "", start, end);
if (written < 0 || (size_t)written >= range_count * 48U + 1U - used) {
free(range_detail);
range_detail = NULL;
break;
}
used += (size_t)written;
}
detail = range_detail;
}
}
}
text = malloc(1536);
if (text) {
@@ -542,6 +578,7 @@ static char *ha_coverage_context(const char *envelope, const char *rel, bool *is
status, freshness ? freshness : "unavailable");
}
}
free(range_detail);
}
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
@@ -611,6 +648,7 @@ static char *ha_resolve_coverage(cbm_mcp_server_t *srv, const char *file_path) {
yyjson_mut_obj_add_str(adoc, aroot, "project", project);
yyjson_mut_arr_add_strcpy(adoc, paths, rel);
yyjson_mut_obj_add_val(adoc, aroot, "paths", paths);
yyjson_mut_obj_add_str(adoc, aroot, "format", "json");
char *args = yyjson_mut_write(adoc, 0, NULL);
yyjson_mut_doc_free(adoc);
free(project);
@@ -811,53 +849,108 @@ static char *ha_registry_project_for_path(cbm_mcp_server_t *srv, const char *cwd
if (!ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) {
return NULL;
}
char *envelope = cbm_mcp_handle_tool(srv, "list_projects", "{\"metadata_only\":true}");
yyjson_doc *doc = envelope ? yyjson_read(envelope, strlen(envelope), 0) : NULL;
free(envelope);
if (!doc) {
return NULL;
}
yyjson_val *outer = yyjson_doc_get_root(doc);
yyjson_val *error = yyjson_obj_get(outer, "isError");
yyjson_val *structured = yyjson_obj_get(outer, "structuredContent");
yyjson_val *projects = structured ? yyjson_obj_get(structured, "projects") : NULL;
if ((error && yyjson_is_true(error)) || !projects || !yyjson_is_arr(projects)) {
yyjson_doc_free(doc);
return NULL;
}
const char *best_name = NULL;
char *best_name = NULL;
char best_root[4096] = {0};
size_t best_length = 0U;
size_t index;
size_t maximum;
yyjson_val *project;
yyjson_arr_foreach(projects, index, maximum, project) {
const char *name = ha_obj_str(project, "name");
const char *root = ha_obj_str(project, "root_path");
char canonical_root[4096];
if (!name || !name[0] || !root || !root[0] ||
!ha_canonical_path(root, canonical_root, sizeof(canonical_root)) ||
!ha_path_contains(canonical_root, canonical_cwd)) {
continue;
int64_t offset = 0;
bool complete = false;
for (;;) {
char args[160];
int written = snprintf(args, sizeof(args),
"{\"metadata_only\":true,\"format\":\"json\",\"limit\":%d,"
"\"offset\":%lld}",
HA_LIST_PAGE_LIMIT, (long long)offset);
if (written < 0 || (size_t)written >= sizeof(args)) {
break;
}
size_t length = strlen(canonical_root);
if (length > best_length) {
best_name = name;
best_length = length;
snprintf(best_root, sizeof(best_root), "%s", canonical_root);
char *envelope = cbm_mcp_handle_tool(srv, "list_projects", args);
yyjson_doc *edoc = envelope ? yyjson_read(envelope, strlen(envelope), 0) : NULL;
free(envelope);
if (!edoc) {
break;
}
yyjson_val *outer = yyjson_doc_get_root(edoc);
yyjson_val *error = yyjson_obj_get(outer, "isError");
yyjson_val *content = yyjson_obj_get(outer, "content");
yyjson_val *item0 = content && yyjson_is_arr(content) ? yyjson_arr_get(content, 0) : NULL;
const char *inner = ha_obj_str(item0, "text");
yyjson_doc *idoc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
if ((error && yyjson_is_true(error)) || !idoc) {
if (idoc) {
yyjson_doc_free(idoc);
}
yyjson_doc_free(edoc);
break;
}
yyjson_val *root = yyjson_doc_get_root(idoc);
yyjson_val *projects = yyjson_obj_get(root, "projects");
if (!projects || !yyjson_is_arr(projects)) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
break;
}
size_t index;
size_t maximum;
yyjson_val *project;
yyjson_arr_foreach(projects, index, maximum, project) {
const char *name = ha_obj_str(project, "name");
const char *project_root = ha_obj_str(project, "root_path");
char canonical_root[4096];
if (!name || !name[0] || !project_root || !project_root[0] ||
!ha_canonical_path(project_root, canonical_root, sizeof(canonical_root)) ||
!ha_path_contains(canonical_root, canonical_cwd)) {
continue;
}
size_t length = strlen(canonical_root);
if (length > best_length) {
char *candidate = strdup(name);
if (!candidate) {
continue;
}
free(best_name);
best_name = candidate;
best_length = length;
snprintf(best_root, sizeof(best_root), "%s", canonical_root);
}
}
yyjson_val *has_more = yyjson_obj_get(root, "has_more");
if (!has_more || !yyjson_is_bool(has_more)) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
break;
}
bool more = yyjson_is_true(has_more);
if (!more) {
complete = true;
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
break;
}
yyjson_val *next = yyjson_obj_get(root, "next_offset");
int64_t next_offset = next && yyjson_is_int(next) ? yyjson_get_int(next) : -1;
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
if (next_offset <= offset) {
break;
}
offset = next_offset;
}
char *result = best_name ? strdup(best_name) : NULL;
if (result && root_out && root_out_size > 0U) {
if (!complete) {
free(best_name);
return NULL;
}
if (best_name && root_out && root_out_size > 0U) {
int written = snprintf(root_out, root_out_size, "%s", best_root);
if (written < 0 || (size_t)written >= root_out_size) {
free(result);
result = NULL;
free(best_name);
best_name = NULL;
}
}
yyjson_doc_free(doc);
return result;
return best_name;
}
/* Return the nearest indexed graph project for cwd. Probe derived names first
@@ -878,6 +971,7 @@ static char *ha_resolve_indexed_project_with_root(cbm_mcp_server_t *srv, const c
if (doc && root) {
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_obj_add_str(doc, root, "project", project);
yyjson_mut_obj_add_str(doc, root, "format", "json");
char *args = yyjson_mut_write(doc, 0, NULL);
yyjson_mut_doc_free(doc);
if (args) {
+118 -2
View File
@@ -31,6 +31,9 @@ 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 bool s_preserve_details;
static cbm_mutex_t s_sink_mutex;
static atomic_int s_sink_mutex_state = ATOMIC_VAR_INIT(LOCK_UNINITIALIZED);
@@ -47,8 +50,69 @@ static void progress_sink_mutex_ensure(void) {
while (atomic_load_explicit(&s_sink_mutex_state, memory_order_acquire) != LOCK_READY) {}
}
bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty) {
return explicitly_requested || stderr_is_tty;
bool cbm_cli_output_flags_parse(int *argc, char **argv, cbm_cli_output_flags_t *flags, char *error,
size_t error_size) {
if (!argc || !argv || !flags) {
return false;
}
memset(flags, 0, sizeof(*flags));
if (error && error_size > 0) {
error[0] = '\0';
}
/* --quiet and --progress are reserved process controls wherever they
* appear, matching the existing --progress behavior. --verbose remains
* outer-only because tools such as index_status own a verbose input. */
bool before_tool = true;
int output_index = 0;
for (int input_index = 0; input_index < *argc; input_index++) {
char *arg = argv[input_index];
bool quiet = arg && strcmp(arg, "--quiet") == 0;
bool progress = arg && strcmp(arg, "--progress") == 0;
bool verbose = before_tool && arg && strcmp(arg, "--verbose") == 0;
if (quiet) {
flags->quiet_requested = true;
} else if (progress) {
flags->progress_requested = true;
} else if (verbose) {
flags->verbose_requested = true;
} else {
argv[output_index++] = arg;
if (before_tool && arg && arg[0] != '-') {
before_tool = false;
}
}
}
*argc = output_index;
if (flags->quiet_requested && (flags->progress_requested || flags->verbose_requested)) {
if (error && error_size > 0) {
(void)snprintf(error, error_size,
"--quiet cannot be combined with --progress or outer --verbose; "
"use --quiet for errors-only stderr, --progress for lifecycle "
"feedback, or --verbose for informational diagnostics");
}
return false;
}
return true;
}
void cbm_cli_diagnostics_configure(bool quiet_requested, bool verbose_requested) {
if (quiet_requested) {
/* Keep ERROR records visible while suppressing DEBUG/INFO/WARN. An
* explicit CBM_LOG_LEVEL=none remains stronger than --quiet. */
if (cbm_log_get_level() < CBM_LOG_ERROR) {
cbm_log_set_level(CBM_LOG_ERROR);
}
return;
}
if (verbose_requested && cbm_log_get_level() > CBM_LOG_INFO) {
cbm_log_set_level(CBM_LOG_INFO);
}
}
bool cbm_cli_progress_enabled(bool explicitly_requested, bool quiet_requested, bool stderr_is_tty) {
return !quiet_requested && (explicitly_requested || stderr_is_tty);
}
static void progress_tool_name(const char *tool_name, char out[CBM_SZ_64]) {
@@ -184,6 +248,14 @@ 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_preserve_details = s_previous_log_level <= CBM_LOG_INFO;
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 +269,11 @@ 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;
}
s_preserve_details = false;
cbm_mutex_unlock(&s_sink_mutex);
}
@@ -338,6 +415,20 @@ 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 and, when INFO/DEBUG was already selected before progress
* initialization, preserve unrecognized diagnostic lines as well. Logger
* callbacks do not include a trailing newline. */
static void passthrough_line(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 +436,28 @@ 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_line(line);
cbm_mutex_unlock(&s_sink_mutex);
return;
}
if (strcmp(level, "debug") == 0) {
if (s_preserve_details) {
passthrough_line(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);
@@ -357,5 +470,8 @@ void cbm_progress_sink_fn(const char *line) {
return;
}
}
if (s_preserve_details) {
passthrough_line(line);
}
cbm_mutex_unlock(&s_sink_mutex);
}
+18 -3
View File
@@ -3,7 +3,7 @@
*
* Installs a log sink that maps structured pipeline events to phase labels.
* Interactive terminals enable it automatically; --progress forces it when
* stderr is redirected.
* stderr is redirected, and --quiet disables it.
* Usage:
* cbm_progress_sink_init(stderr);
* // ... run pipeline ...
@@ -13,12 +13,27 @@
#define CBM_PROGRESS_SINK_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
typedef struct {
bool quiet_requested;
bool progress_requested;
bool verbose_requested;
} cbm_cli_output_flags_t;
/* Parse process-level output controls without consuming a tool's own
* `--verbose` argument. The argv array is compacted in place. */
bool cbm_cli_output_flags_parse(int *argc, char **argv, cbm_cli_output_flags_t *flags, char *error,
size_t error_size);
/* Apply process-level diagnostic verbosity without changing stdout. */
void cbm_cli_diagnostics_configure(bool quiet_requested, bool verbose_requested);
/* Interactive terminals get lifecycle feedback automatically. --progress
* forces the same behavior for redirected stderr without touching stdout. */
bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty);
* forces the same behavior for redirected stderr; --quiet disables both. */
bool cbm_cli_progress_enabled(bool explicitly_requested, bool quiet_requested, bool stderr_is_tty);
void cbm_cli_progress_start(FILE *out, const char *tool_name);
void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, uint64_t elapsed_ms);
+78 -8
View File
@@ -7,6 +7,7 @@
*/
#include "cypher/cypher.h"
#include "foundation/compat.h"
#include "foundation/constants.h"
#include "store/store.h"
#include "foundation/platform.h"
#include "foundation/limits.h"
@@ -2880,6 +2881,9 @@ static void rb_add_row(result_builder_t *rb, const char **values) {
static _Thread_local uint64_t g_cypher_deadline_ms = 0; /* absolute; 0 = disarmed */
static _Thread_local bool g_cypher_timed_out = false;
/* Sticky for one cbm_cypher_execute call. Unlike row_count, this survives caps
* that happen before DISTINCT, aggregation, ORDER BY, or the final projection. */
static _Thread_local bool g_cypher_truncated = false;
static _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */
static void cypher_deadline_arm(void) {
@@ -3237,6 +3241,10 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
* clamp — never a silent truncation. */
int depth_cap = cbm_cypher_max_depth();
int max_depth = rel->max_hops > 0 ? rel->max_hops : depth_cap;
/* A range clamped to the engine ceiling (or an unbounded one) is probed one
* hop beyond it: a candidate out there means the clamp hid real rows, so the
* result is reported truncated; a clamp on a shallow graph stays a warning. */
bool probe_beyond_depth_cap = rel->max_hops <= 0 || max_depth > depth_cap;
if (max_depth > depth_cap) {
char req_buf[16];
char cap_buf[16];
@@ -3248,8 +3256,9 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
}
cbm_traverse_result_t tr = {0};
const char *dir = rel->direction ? rel->direction : "outbound";
cbm_store_bfs_trail(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT,
&tr);
int traversal_depth = probe_beyond_depth_cap ? depth_cap + SKIP_ONE : max_depth;
cbm_store_bfs_trail(store, src->id, dir, rel->types, rel->type_count, traversal_depth,
CBM_PERCENT, &tr);
if (tr.truncated) {
g_cypher_trail_truncated = 1;
}
@@ -3260,6 +3269,10 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
* fabricated OPTIONAL "no match" row. */
for (int v = 0; v < tr.visited_count; v++) {
cbm_node_hop_t *hop = &tr.visited[v];
if (hop->hop > max_depth) {
g_cypher_truncated = true; /* the probe hop found a candidate past the cap */
continue;
}
if (hop->hop < rel->min_hops) {
continue;
}
@@ -3494,7 +3507,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 +3530,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 +3776,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 +4356,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 +4617,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 +4631,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 +4660,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 +4681,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 +4976,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 +5215,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 +5241,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 +5254,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 +5278,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 +5288,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 +5300,9 @@ 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;
/* Any internal ceiling that prevented exhaustive evaluation: a candidate or
* traversal budget, or a variable-length range clamped to the engine cap. */
out->truncated = g_cypher_truncated || g_cypher_trail_truncated != 0;
if (g_cypher_depth_clamped > 0 || g_cypher_trail_truncated) {
char wbuf[CBM_SZ_256];
if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) {
+4
View File
@@ -321,6 +321,10 @@ typedef struct {
/* rows[row_idx][col_idx] = string value */
const char ***rows;
int row_count;
/* True when an internal row/candidate ceiling prevented exhaustive
* evaluation. A Cypher LIMIT is part of query semantics and does not set
* this flag by itself. */
bool truncated;
/* Non-NULL when the query was rejected (e.g. result too large) */
char *error;
/* Non-NULL advisory (caller-visible, not an error): e.g. a variable-
+11 -7
View File
@@ -123,17 +123,20 @@ cbm_dirent_t *cbm_readdir(cbm_dir_t *d) {
int cbm_path_info_utf8(const char *path, cbm_path_info_t *out) {
if (!path || !out) {
return CBM_NOT_FOUND;
return CBM_PATH_INFO_UNAVAILABLE;
}
wchar_t *wpath = cbm_path_to_wide(path);
if (!wpath) {
return CBM_NOT_FOUND;
return CBM_PATH_INFO_UNAVAILABLE;
}
WIN32_FILE_ATTRIBUTE_DATA data;
BOOL ok = GetFileAttributesExW(wpath, GetFileExInfoStandard, &data);
DWORD path_error = ok ? ERROR_SUCCESS : GetLastError();
free(wpath);
if (!ok) {
return CBM_NOT_FOUND;
return path_error == ERROR_FILE_NOT_FOUND || path_error == ERROR_PATH_NOT_FOUND
? CBM_PATH_INFO_ABSENT
: CBM_PATH_INFO_UNAVAILABLE;
}
memset(out, 0, sizeof(*out));
out->is_directory = (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
@@ -154,7 +157,7 @@ int cbm_path_info_utf8(const char *path, cbm_path_info_t *out) {
written >= windows_to_unix_ticks
? (int64_t)((written - windows_to_unix_ticks) * NANOSECONDS_PER_WINDOWS_TICK)
: 0;
return 0;
return CBM_PATH_INFO_OK;
}
void cbm_closedir(cbm_dir_t *d) {
@@ -795,11 +798,12 @@ cbm_dirent_t *cbm_readdir(cbm_dir_t *d) {
int cbm_path_info_utf8(const char *path, cbm_path_info_t *out) {
if (!path || !out) {
return CBM_NOT_FOUND;
return CBM_PATH_INFO_UNAVAILABLE;
}
struct stat state;
if (lstat(path, &state) != 0) {
return CBM_NOT_FOUND;
return errno == ENOENT || errno == ENOTDIR ? CBM_PATH_INFO_ABSENT
: CBM_PATH_INFO_UNAVAILABLE;
}
memset(out, 0, sizeof(*out));
out->is_regular = S_ISREG(state.st_mode);
@@ -813,7 +817,7 @@ int cbm_path_info_utf8(const char *path, cbm_path_info_t *out) {
out->mtime_ns =
((int64_t)state.st_mtim.tv_sec * INT64_C(1000000000)) + (int64_t)state.st_mtim.tv_nsec;
#endif
return 0;
return CBM_PATH_INFO_OK;
}
void cbm_closedir(cbm_dir_t *d) {
+9 -1
View File
@@ -36,7 +36,15 @@ typedef struct {
int64_t mtime_ns;
} cbm_path_info_t;
/* Returns 0 on success and -1 when the path cannot be inspected. */
enum {
CBM_PATH_INFO_OK = 0,
CBM_PATH_INFO_UNAVAILABLE = -1,
CBM_PATH_INFO_ABSENT = -2,
};
/* Distinguishes a proven absent path from permission, encoding, and transient
* inspection failures. Existing callers that only test zero/nonzero retain
* their behavior. */
int cbm_path_info_utf8(const char *path, cbm_path_info_t *out);
/* Open a directory for iteration. Returns NULL on error. */
+8
View File
@@ -106,6 +106,14 @@ parse_format:;
* silently change the operator-selected output shape. */
}
void cbm_log_init_for_process(bool quiet_by_default, bool require_info_liveness) {
cbm_log_set_level(quiet_by_default ? CBM_LOG_WARN : CBM_LOG_INFO);
cbm_log_init_from_env();
if (require_info_liveness && cbm_log_get_level() > CBM_LOG_INFO) {
cbm_log_set_level(CBM_LOG_INFO);
}
}
void cbm_log_set_sink(cbm_log_sink_fn fn) {
cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
}
+8 -1
View File
@@ -44,7 +44,14 @@ 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). */
/* Apply a role-aware startup policy, then the environment override. Thin
* frontends pass quiet_by_default=true; detached daemons pass false. Physical
* workers also pass require_info_liveness=true because their private log is
* the supervisor's no-progress heartbeat, so WARN/NONE cannot suppress INFO
* lifecycle records and turn a healthy long index into a false hang. */
void cbm_log_init_for_process(bool quiet_by_default, bool require_info_liveness);
/* Set minimum log level (library default: INFO; process startup policy is role-aware). */
void cbm_log_set_level(CBMLogLevel level);
/* Get current log level. */
+89 -31
View File
@@ -6,6 +6,8 @@
* cli <tool> <json> Run a single tool call and print result
* --version Print version and exit
* --help Print usage and exit
* cli --quiet Show errors only; disable automatic terminal progress
* 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 +541,13 @@ 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 [--quiet] [--progress] [--verbose] [--json] <tool_name> " \
"[json_args]\n" \
" --quiet Show errors only; cannot combine with --progress or outer --verbose\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":...}
@@ -665,10 +673,21 @@ static bool cli_first_nonspace_is_brace(const char *s) {
return *s == '{';
}
static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json);
static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json,
bool quiet_requested);
static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_locks,
main_local_maintenance_context_t *maintenance_context) {
cbm_cli_output_flags_t output_flags;
char output_error[CBM_SZ_512] = {0};
if (!cbm_cli_output_flags_parse(&argc, argv, &output_flags, output_error,
sizeof(output_error))) {
(void)fprintf(stderr, "error: %s\n",
output_error[0] ? output_error : "invalid output mode");
(void)fprintf(stderr, CLI_USAGE);
return SKIP_ONE;
}
cbm_cli_diagnostics_configure(output_flags.quiet_requested, output_flags.verbose_requested);
if (argc == 1 && argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) {
(void)fputs(CLI_USAGE, stdout);
return 0;
@@ -678,7 +697,6 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
return SKIP_ONE;
}
bool progress_requested = cli_strip_flag(&argc, argv, "--progress");
bool raw_json = cli_strip_flag(&argc, argv, "--json");
/* Supervisor worker role: when this process was spawned as a supervised index
@@ -746,11 +764,13 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
} else if (rem_argc >= SKIP_ONE && cli_first_nonspace_is_brace(rem_argv[0])) {
/* raw-JSON back-compat: cli <tool> '{"k":"v"}' (deprecated path). Warn on
* STDERR only — stdout must stay clean JSON for piping. */
(void)fprintf(stderr,
"warning: passing raw JSON to 'cli %s' is deprecated and "
"will be removed in a future release; use flags (run 'cli "
"%s --help'), --args-file <path>, or piped stdin.\n",
tool_name, tool_name);
if (!output_flags.quiet_requested) {
(void)fprintf(stderr,
"warning: passing raw JSON to 'cli %s' is deprecated and "
"will be removed in a future release; use flags (run 'cli "
"%s --help'), --args-file <path>, or piped stdin.\n",
tool_name, tool_name);
}
args_json = rem_argv[0];
} else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) {
/* flag form: cli <tool> --flag value --bare-bool ... */
@@ -777,7 +797,8 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
}
bool progress =
!index_worker && cbm_cli_progress_enabled(progress_requested, cli_isatty(2) != 0);
!index_worker && cbm_cli_progress_enabled(output_flags.progress_requested,
output_flags.quiet_requested, cli_isatty(2) != 0);
uint64_t progress_started_ms = cbm_now_ms();
if (progress) {
cbm_progress_sink_init(stderr);
@@ -797,7 +818,7 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
bool maintenance_binding_failed = false;
bool maintenance_cancelled = false;
if (!index_worker) {
result = main_local_cli_daemon_execute(tool_name, args_json);
result = main_local_cli_daemon_execute(tool_name, args_json, output_flags.quiet_requested);
} else {
srv = cbm_mcp_server_new(NULL);
if (srv) {
@@ -898,7 +919,8 @@ 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 [--quiet] [--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");
@@ -909,6 +931,12 @@ static void print_help(void) {
printf(" codebase-memory-mcp config <list|get|set|reset>\n");
printf(" codebase-memory-mcp --version Print version\n");
printf(" codebase-memory-mcp --help Print this help\n");
printf("\nCLI output options:\n");
printf(" --quiet Show errors only; disable automatic terminal progress\n");
printf(" --progress Show lifecycle progress even with redirected stderr\n");
printf(" --verbose Include informational diagnostics\n");
printf(" --json Print the raw MCP result envelope\n");
printf(" --quiet cannot be combined with --progress or outer --verbose.\n");
printf("\nUI options:\n");
printf(" --ui=true Enable HTTP graph visualization (persisted)\n");
printf(" --ui=false Disable HTTP graph visualization (persisted)\n");
@@ -1285,15 +1313,23 @@ static cbm_daemon_ipc_endpoint_t *main_daemon_endpoint_new(void) {
return cbm_daemon_bootstrap_endpoint_new(runtime_parent);
}
static bool main_local_cli_feedback_enabled(int argc, char **argv) {
bool requested = false;
static bool main_cli_flag_present(int argc, char **argv, const char *flag) {
if (!argv || !flag) {
return false;
}
for (int index = 1; index < argc; index++) {
if (argv[index] && strcmp(argv[index], "--progress") == 0) {
requested = true;
break;
if (argv[index] && strcmp(argv[index], flag) == 0) {
return true;
}
}
return cbm_cli_progress_enabled(requested, cli_isatty(2) != 0);
return false;
}
static bool main_local_cli_feedback_enabled(int argc, char **argv) {
bool requested = false;
requested = main_cli_flag_present(argc, argv, "--progress");
bool quiet = main_cli_flag_present(argc, argv, "--quiet");
return cbm_cli_progress_enabled(requested, quiet, cli_isatty(2) != 0);
}
/* Acquire the exclusive startup transition, waiting out whatever currently holds it.
@@ -1546,7 +1582,8 @@ static void main_report_client_bootstrap_failure(cbm_daemon_process_role_t role,
* lifecycle used to, instead of deadlocking behind the pinned daemon. Same-
* or newer-build daemons and dev builds are never auto-drained. */
static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade(
const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result) {
const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result,
bool report_lifecycle) {
cbm_daemon_bootstrap_status_t status = cbm_daemon_bootstrap_execute(config, result);
if (status != CBM_DAEMON_BOOTSTRAP_CONFLICT) {
return status;
@@ -1558,11 +1595,13 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade(
!main_semver_newer(config->identity->semantic_version, active.semantic_version)) {
return status;
}
(void)fprintf(stderr,
"codebase-memory-mcp: retiring the active permanent daemon (%s, pid %lu) for "
"this newer build (%s)\n",
active.semantic_version, (unsigned long)active.daemon_pid,
config->identity->semantic_version);
if (report_lifecycle) {
(void)fprintf(stderr,
"codebase-memory-mcp: retiring the active permanent daemon (%s, pid %lu) "
"for this newer build (%s)\n",
active.semantic_version, (unsigned long)active.daemon_pid,
config->identity->semantic_version);
}
cbm_daemon_runtime_activation_result_t drain;
if (!cbm_daemon_runtime_request_activation_shutdown(config->endpoint, config->identity,
CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE,
@@ -1579,8 +1618,9 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade(
* sessions and hooks: an active daemon (any starter) is recycled, an absent
* one is spawned for this command — with a hint that `daemon start` removes
* that per-command cost. Only supervised index workers stay in-process. */
static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json) {
cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_bootstrap_endpoint_new(NULL);
static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json,
bool quiet_requested) {
cbm_daemon_ipc_endpoint_t *endpoint = main_daemon_endpoint_new();
char executable_path[MAIN_PATH_CAP] = {0};
cbm_daemon_build_identity_t identity;
bool prepared =
@@ -1601,7 +1641,8 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar
.startup_timeout_ms = MAIN_MCP_STARTUP_TIMEOUT_MS,
};
cbm_daemon_bootstrap_result_t bootstrap;
cbm_daemon_bootstrap_status_t status = main_client_bootstrap_with_upgrade(&config, &bootstrap);
cbm_daemon_bootstrap_status_t status =
main_client_bootstrap_with_upgrade(&config, &bootstrap, !quiet_requested);
cbm_daemon_ipc_endpoint_free(endpoint);
if (status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap.client) {
(void)fprintf(stderr, "error: %s\n",
@@ -1609,7 +1650,12 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar
: "no CBM daemon connection for CLI execution");
return NULL;
}
if (bootstrap.daemon_spawned) {
if (bootstrap.daemon_spawned && cbm_log_get_level() <= CBM_LOG_INFO) {
/* Routine cold-start advice is informational. Default one-shot CLI
* output stays pipe-clean; `cli --verbose ...` (or CBM_LOG_LEVEL=info)
* opts into this detail. It is advice for a person, so it is a plain
* stderr line rather than a structured record whose value escaping
* would turn `daemon start` into `daemon_start`. */
(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");
@@ -2400,7 +2446,7 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
};
cbm_daemon_bootstrap_result_t start_result;
cbm_daemon_bootstrap_status_t start_status =
main_client_bootstrap_with_upgrade(&start_config, &start_result);
main_client_bootstrap_with_upgrade(&start_config, &start_result, true);
if (start_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !start_result.client) {
(void)fprintf(stderr, "error: %s\n",
start_result.message[0] ? start_result.message
@@ -2513,7 +2559,19 @@ int main(int argc, char **argv) {
cbm_cli_set_version(CBM_VERSION);
cbm_profile_init();
cbm_log_init_from_env();
/* The library default stays INFO (embedders and the test runner observe
* INFO records through the sink); the process policy is applied here, as
* early as argv can be classified: thin frontends go quiet, detached
* daemons retain INFO lifecycle records, and physical workers require
* INFO because those records are their supervisor's quiet-timeout
* heartbeat. */
bool quiet_log_default = role != CBM_DAEMON_PROCESS_DAEMON && role != CBM_DAEMON_PROCESS_WORKER;
cbm_log_init_for_process(quiet_log_default, role == CBM_DAEMON_PROCESS_WORKER);
if (role == CBM_DAEMON_PROCESS_LOCAL_CLI && main_cli_flag_present(argc, argv, "--quiet")) {
/* Apply quiet before local coordination begins, not only once tool
* dispatch is reached, so pre-dispatch WARN chatter is suppressed. */
cbm_cli_diagnostics_configure(true, false);
}
cbm_mcp_tool_profile_t tool_profile = CBM_MCP_TOOL_PROFILE_ALL;
if (role == CBM_DAEMON_PROCESS_MCP_CLIENT &&
@@ -2571,7 +2629,7 @@ int main(int argc, char **argv) {
(void)fputs("Preparing one-shot local CBM command...\n", feedback);
(void)fflush(feedback);
}
cbm_daemon_ipc_endpoint_t *local_endpoint = cbm_daemon_bootstrap_endpoint_new(NULL);
cbm_daemon_ipc_endpoint_t *local_endpoint = main_daemon_endpoint_new();
char local_executable[MAIN_PATH_CAP];
cbm_daemon_build_identity_t local_identity;
cbm_project_lock_manager_t *project_locks =
@@ -3011,7 +3069,7 @@ int main(int argc, char **argv) {
};
cbm_daemon_bootstrap_result_t bootstrap_result;
cbm_daemon_bootstrap_status_t bootstrap_status =
main_client_bootstrap_with_upgrade(&bootstrap_config, &bootstrap_result);
main_client_bootstrap_with_upgrade(&bootstrap_config, &bootstrap_result, true);
cbm_daemon_ipc_endpoint_free(endpoint);
if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) {
main_report_client_bootstrap_failure(role, &bootstrap_result);
+949 -44
View File
File diff suppressed because it is too large Load Diff
+35 -6
View File
@@ -1,11 +1,9 @@
/* compact_out.h — TOON (Token-Oriented Object Notation) emission helpers.
/* compact_out.h — compact tree emission helpers.
*
* Tool responses are consumed by LLM agents, where every byte is context
* tokens. TOON encodes the same data as JSON but declares tabular fields
* once in a header and streams rows line by line, cutting 40-60% of tokens
* on homogeneous result sets at equal-or-better retrieval accuracy
* (toonformat.dev/guide/benchmarks). Emitters here cover the subset we
* emit: scalar key-value lines and flat tables with explicit [N] lengths.
* tokens. The tree format declares tabular fields once in a header and
* streams rows line by line, removing repeated-key overhead while retaining
* scalar key-value lines and explicit table lengths.
*
* Quoting: a cell/scalar is double-quoted iff it is empty, has leading or
* trailing whitespace, contains a comma, quote, newline, or CR, or would
@@ -33,6 +31,13 @@ void cbm_sb_append(cbm_sb_t *sb, const char *s);
char *cbm_sb_finish(cbm_sb_t *sb);
void cbm_sb_free(cbm_sb_t *sb);
/* Make an arbitrary byte string safe for both standard JSON and the compact
* tree without losing identity. Valid UTF-8 is left untouched (`*encoded` is
* NULL). Invalid input becomes `@bytes:<lowercase hex>`; valid strings that
* begin with the reserved `@bytes:` or `@utf8:` prefixes become
* `@utf8:<original>`. The caller frees a non-NULL `*encoded`. */
bool cbm_output_encode_text(const char *data, size_t len, char **encoded, size_t *encoded_len);
/* `key: value` scalar lines (top-level, no indent). */
void cbm_tree_scalar_str(cbm_sb_t *sb, const char *key, const char *val);
void cbm_tree_scalar_int(cbm_sb_t *sb, const char *key, long long v);
@@ -51,4 +56,28 @@ 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 the exact
* rendering saves at least 15% and 64 bytes and a conservative model-neutral
* token-shape proxy saves at least 1%. */
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 */
+6236 -1882
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -75,7 +75,6 @@ const char *cbm_mcp_tool_name(int index);
/* Tool metadata by name (static; do not free; NULL when unknown). Used by the
* generated client adapters so they can emit descriptions and parameter
* schemas without drifting from tools/list. */
const char *cbm_mcp_tool_title(const char *tool_name);
const char *cbm_mcp_tool_description(const char *tool_name);
/* Render the top-level --help "Tools:" block from the registry so the help
+28
View File
@@ -75,5 +75,33 @@ 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
/* Reject opening one changed file while detect_changes fingerprints its live
* snapshot. This makes otherwise platform-specific permission/read races
* deterministic without changing production filesystem behavior. */
typedef bool (*cbm_mcp_snapshot_read_test_hook_fn)(void *context, const char *absolute_path);
void cbm_mcp_server_set_snapshot_read_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_snapshot_read_test_hook_fn hook,
void *context);
/* 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);
/* Drive the production raw-source slicer and renderer without relying on an
* external grep/PowerShell text decoder. This keeps malformed-byte paging
* deterministic on every test platform. */
char *cbm_mcp_render_raw_preview_for_testing(const char *content, bool content_offset_set,
size_t content_offset, bool json_format);
/* Exercise search_graph's semantic continuation metadata without allocating
* a 100k-row vector fixture. The production emitters remain the code under
* test; this seam only supplies their already-ranked page descriptor. */
char *cbm_mcp_render_semantic_paging_for_testing(int total, int offset, int returned, int limit,
bool total_exact, bool json_format);
#endif
#endif
+10
View File
@@ -1815,6 +1815,16 @@ int cbm_pipeline_publish_staged(char *stage_path, const cbm_pipeline_generation_
}
cbm_log_info("publish.timing", "block", "fts", "elapsed_ms", itoa_buf((int)elapsed_ms(t_pub)));
cbm_clock_gettime(CLOCK_MONOTONIC, &t_pub);
/* This is the shared commit tail for complete rebuilds and isolated
* deltas. Stamp only after every graph/metadata/FTS mutation: a fresh
* staging file receives a new uid, while a cloned delta keeps its uid and
* advances the mutation counter. A failure discards the private stage. */
if (ok && cbm_store_generation_advance(store) != CBM_STORE_OK) {
ok = false;
}
cbm_log_info("publish.timing", "block", "generation", "elapsed_ms",
itoa_buf((int)elapsed_ms(t_pub)));
cbm_clock_gettime(CLOCK_MONOTONIC, &t_pub);
if (ok && !cbm_store_check_integrity(store)) {
ok = false;
}
+382 -101
View File
@@ -8,6 +8,7 @@
// for ISO timestamp
#include <limits.h>
#include <stdint.h>
#include "foundation/constants.h"
#include "foundation/hash_table.h"
@@ -2174,13 +2175,214 @@ int cbm_store_dump_to_file(cbm_store_t *s, const char *dest_path) {
/* ── Project CRUD ───────────────────────────────────────────────── */
static int rollback_savepoint_preserving_error(cbm_store_t *s, const char *rollback_sql,
const char *release_sql) {
char saved_error[sizeof(s->errbuf)];
snprintf(saved_error, sizeof(saved_error), "%s", s->errbuf);
(void)sqlite3_exec(s->db, rollback_sql, NULL, NULL, NULL);
(void)sqlite3_exec(s->db, release_sql, NULL, NULL, NULL);
snprintf(s->errbuf, sizeof(s->errbuf), "%s", saved_error);
return CBM_STORE_ERR;
}
static bool generation_uid_is_canonical(const char *uid) {
if (!uid || strlen(uid) != 16) {
return false;
}
for (size_t i = 0; i < 16; i++) {
if (!((uid[i] >= '0' && uid[i] <= '9') || (uid[i] >= 'a' && uid[i] <= 'f'))) {
return false;
}
}
return true;
}
static bool generation_counter_parse(const char *text, uint64_t *out) {
if (!text || !text[0] || (text[0] == '0' && text[1] != '\0')) {
return false;
}
uint64_t value = 0;
for (const unsigned char *p = (const unsigned char *)text; *p; p++) {
if (*p < '0' || *p > '9') {
return false;
}
uint64_t digit = (uint64_t)(*p - '0');
if (value > (UINT64_MAX - digit) / 10) {
return false;
}
value = value * 10 + digit;
}
*out = value;
return true;
}
/* Read and validate the two generation keys. Extra store_meta keys belong to
* other store features and are deliberately ignored. */
static int generation_metadata_read(cbm_store_t *s, bool *absent, char uid[17], char counter[21],
uint64_t *counter_value) {
*absent = false;
sqlite3_stmt *object = NULL;
int rc = sqlite3_prepare_v2(s->db,
"SELECT type FROM sqlite_schema WHERE name='store_meta' "
"AND type IN ('table','view') LIMIT 1;",
CBM_NOT_FOUND, &object, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "store generation schema probe prepare");
return CBM_STORE_ERR;
}
rc = sqlite3_step(object);
if (rc == SQLITE_DONE) {
sqlite3_finalize(object);
*absent = true;
return CBM_STORE_OK;
}
if (rc != SQLITE_ROW) {
store_set_error_sqlite(s, "store generation schema probe");
sqlite3_finalize(object);
return CBM_STORE_ERR;
}
const char *object_type = (const char *)sqlite3_column_text(object, 0);
bool is_table = object_type && strcmp(object_type, "table") == 0;
sqlite3_finalize(object);
if (!is_table) {
store_set_error(s, "store generation metadata is not a table");
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(s->db,
"SELECT k, v, typeof(v) FROM store_meta "
"WHERE k IN ('db_uid','mutation_gen') ORDER BY k;",
CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "store generation metadata prepare");
return CBM_STORE_ERR;
}
bool have_uid = false;
bool have_counter = false;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *key = (const char *)sqlite3_column_text(stmt, 0);
const char *value = (const char *)sqlite3_column_text(stmt, 1);
const char *value_type = (const char *)sqlite3_column_text(stmt, 2);
if (!key || !value || !value_type || strcmp(value_type, "text") != 0) {
store_set_error(s, "store generation metadata has a non-text value");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
if ((size_t)sqlite3_column_bytes(stmt, 0) != strlen(key) ||
(size_t)sqlite3_column_bytes(stmt, 1) != strlen(value) ||
(size_t)sqlite3_column_bytes(stmt, 2) != strlen(value_type)) {
store_set_error(s, "store generation metadata contains an embedded NUL");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
if (strcmp(key, "db_uid") == 0 && !have_uid && generation_uid_is_canonical(value)) {
snprintf(uid, 17, "%s", value);
have_uid = true;
} else if (strcmp(key, "mutation_gen") == 0 && !have_counter &&
generation_counter_parse(value, counter_value)) {
snprintf(counter, 21, "%s", value);
have_counter = true;
} else {
store_set_error(s, "store generation metadata is malformed");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
}
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "store generation metadata scan");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
if (!have_uid || !have_counter) {
store_set_error(s, "store generation metadata is incomplete");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_generation_advance(cbm_store_t *s) {
static const char savepoint[] = "SAVEPOINT cbm_generation_advance;";
static const char rollback[] = "ROLLBACK TO cbm_generation_advance;";
static const char release[] = "RELEASE cbm_generation_advance;";
if (!s || !s->db) {
return CBM_STORE_ERR;
}
int rc = SQLITE_OK;
if (exec_sql(s, savepoint) != CBM_STORE_OK) {
return CBM_STORE_ERR;
}
bool absent = false;
char uid[17] = {0};
char counter[21] = {0};
uint64_t counter_value = 0;
if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK) {
return rollback_savepoint_preserving_error(s, rollback, release);
}
if (absent) {
if (sqlite3_exec(s->db,
"CREATE TABLE store_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);"
"INSERT INTO store_meta VALUES"
"('db_uid', lower(hex(randomblob(8))));"
"INSERT INTO store_meta VALUES('mutation_gen','0');",
NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "store generation metadata create");
return rollback_savepoint_preserving_error(s, rollback, release);
}
if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK ||
absent) {
if (absent) {
store_set_error(s, "store generation metadata create did not persist");
}
return rollback_savepoint_preserving_error(s, rollback, release);
}
}
if (counter_value == UINT64_MAX) {
store_set_error(s, "store generation mutation counter overflow");
return rollback_savepoint_preserving_error(s, rollback, release);
}
char next_counter[21];
snprintf(next_counter, sizeof(next_counter), "%llu", (unsigned long long)(counter_value + 1));
sqlite3_stmt *update = NULL;
if (sqlite3_prepare_v2(s->db, "UPDATE store_meta SET v=?1 WHERE k='mutation_gen' AND v=?2;",
CBM_NOT_FOUND, &update, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "store generation advance prepare");
return rollback_savepoint_preserving_error(s, rollback, release);
}
bind_text(update, SKIP_ONE, next_counter);
bind_text(update, ST_COL_2, counter);
rc = sqlite3_step(update);
sqlite3_finalize(update);
if (rc != SQLITE_DONE || sqlite3_changes(s->db) != 1) {
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "store generation advance");
} else {
store_set_error(s, "store generation advance changed an unexpected row count");
}
return rollback_savepoint_preserving_error(s, rollback, release);
}
if (exec_sql(s, release) != CBM_STORE_OK) {
return rollback_savepoint_preserving_error(s, rollback, release);
}
return CBM_STORE_OK;
}
int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_path) {
static const char savepoint[] = "SAVEPOINT cbm_upsert_project;";
static const char rollback[] = "ROLLBACK TO cbm_upsert_project;";
static const char release[] = "RELEASE cbm_upsert_project;";
if (!s || !s->db || exec_sql(s, savepoint) != CBM_STORE_OK) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_upsert_project,
"INSERT INTO projects (name, indexed_at, root_path) VALUES (?1, ?2, ?3) "
"ON CONFLICT(name) DO UPDATE SET indexed_at=?2, root_path=?3;");
if (!stmt) {
return CBM_STORE_ERR;
return rollback_savepoint_preserving_error(s, rollback, release);
}
char ts[CBM_SZ_64];
@@ -2193,53 +2395,42 @@ int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_
int rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "upsert_project");
return CBM_STORE_ERR;
sqlite3_reset(stmt);
return rollback_savepoint_preserving_error(s, rollback, release);
}
sqlite3_reset(stmt);
/* Store generation for cursor staleness (pagination): db_uid is a random
* identity minted once per DB FILE (a full reindex publishes a fresh file
* via the writer, which carries no store_meta — the first upsert_project
* on the opened store seeds a NEW uid, so cursors minted against the old
* file can never validate against the rebuilt one, whose node ids all
* differ). mutation_gen increments on every project upsert — the choke
* point every index run (full, incremental, watcher) passes through.
* Created here rather than in the byte-level writer: adding a table to
* its hand-built sqlite_master is rootpage surgery for zero benefit. */
(void)sqlite3_exec(s->db,
"CREATE TABLE IF NOT EXISTS store_meta (k TEXT PRIMARY KEY, v TEXT);"
"INSERT OR IGNORE INTO store_meta VALUES"
"('db_uid', lower(hex(randomblob(8))));"
"INSERT OR IGNORE INTO store_meta VALUES('mutation_gen','0');"
"UPDATE store_meta SET v = CAST(CAST(v AS INTEGER)+1 AS TEXT) "
"WHERE k='mutation_gen';",
NULL, NULL, NULL);
if (cbm_store_generation_advance(s) != CBM_STORE_OK) {
return rollback_savepoint_preserving_error(s, rollback, release);
}
if (exec_sql(s, release) != CBM_STORE_OK) {
return rollback_savepoint_preserving_error(s, rollback, release);
}
return CBM_STORE_OK;
}
/* Opaque store generation for pagination cursors: "u<db_uid>g<mutation_gen>",
* or "legacy" when the DB predates store_meta (read-only opens never create
* it). A cursor whose embedded generation mismatches the store's current one
* is stale — the graph may have changed under it. */
* or "legacy" only when the DB genuinely predates store_meta. */
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) {
if (!s || !s->db || !buf || bufsz == 0) {
return CBM_STORE_ERR;
}
snprintf(buf, bufsz, "legacy");
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT (SELECT v FROM store_meta WHERE k='db_uid'),"
" (SELECT v FROM store_meta WHERE k='mutation_gen');",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_OK; /* pre-migration DB: stable "legacy" generation */
bool absent = false;
char uid[17] = {0};
char counter[21] = {0};
uint64_t counter_value = 0;
if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK) {
buf[0] = '\0';
return CBM_STORE_ERR;
}
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *uid = (const char *)sqlite3_column_text(stmt, 0);
const char *gen = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
if (uid && gen) {
snprintf(buf, bufsz, "u%sg%s", uid, gen);
}
(void)counter_value;
int written =
absent ? snprintf(buf, bufsz, "legacy") : snprintf(buf, bufsz, "u%sg%s", uid, counter);
if (written < 0 || (size_t)written >= bufsz) {
buf[0] = '\0';
store_set_error(s, "store generation output buffer is too small");
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
return CBM_STORE_OK;
}
@@ -4274,8 +4465,18 @@ int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *
return CBM_STORE_ERR;
}
/* A node path can name a graph-only identity such as a Folder,
* <python-builtins>, or another synthetic source. Published indexes have
* one canonical File node per scannable path, so prefer that exact set.
* Legacy/manual stores without File nodes retain their previous node-path
* fallback (minus Folder directories). Missing canonical paths stay in
* the list so search_code still fails closed on a stale snapshot. */
const char *sql = "SELECT DISTINCT file_path FROM nodes "
"WHERE project = ?1 AND file_path IS NOT NULL AND file_path != ''";
"WHERE project = ?1 AND label != 'Folder' "
"AND file_path IS NOT NULL AND file_path != '' "
"AND (label = 'File' OR NOT EXISTS ("
"SELECT 1 FROM nodes WHERE project = ?1 AND label = 'File')) "
"ORDER BY file_path";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
@@ -5393,10 +5594,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 +5613,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 +5652,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 +5699,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 +5762,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);
@@ -5560,6 +5792,8 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
next_id = "e.target_id";
}
/* One extra row makes max_results saturation observable on both branches. */
int probe_limit = max_results < INT_MAX ? max_results + SKIP_ONE : max_results;
int cte_row_limit = 0;
if (trail) {
cte_row_limit = bfs_cte_row_limit_for_depth(max_results, max_depth);
@@ -5588,7 +5822,7 @@ static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
"FROM bfs JOIN nodes n ON n.id = bfs.node_id "
"WHERE bfs.hop > 0 ORDER BY bfs.hop, n.id LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth,
cte_row_limit + SKIP_ONE, max_results);
cte_row_limit + SKIP_ONE, probe_limit);
} else {
snprintf(sql, sizeof(sql),
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) pairs.
@@ -5611,7 +5845,7 @@ 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);
(long long)start_id, next_id, join_cond, types_clause, max_depth, probe_limit);
}
sqlite3_stmt *stmt = NULL;
@@ -5664,14 +5898,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 (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 +5929,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
@@ -9821,6 +10076,18 @@ static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int *
return results;
}
static int vs_ranked_result_cmp(const void *lhs, const void *rhs) {
const cbm_vector_result_t *a = lhs;
const cbm_vector_result_t *b = rhs;
if (a->score > b->score) {
return -1;
}
if (a->score < b->score) {
return 1;
}
return (a->node_id > b->node_id) - (a->node_id < b->node_id);
}
int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords,
int keyword_count, int limit, cbm_vector_result_t **out,
int *out_count) {
@@ -9836,16 +10103,15 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
return CBM_STORE_OK;
}
/* Scan all node vectors, compute per-keyword cosine, take min.
* We use the FIRST keyword as the SQL sort (for top-K pre-filter),
* then re-score with min across all keywords in the append helper. */
/* Use the first keyword for a cheap candidate ordering, then score each
* materialized candidate by min-cosine across every keyword. */
const char *sql = "SELECT n.id, n.name, n.qualified_name, n.file_path, n.label,"
" cbm_cosine_i8(v.vector, ?1) as score, v.vector"
" FROM node_vectors v"
" INNER JOIN nodes n ON n.id = v.node_id"
" WHERE v.project = ?2"
" AND n.label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")"
" ORDER BY score DESC"
" ORDER BY score DESC, n.id ASC"
" LIMIT ?3";
sqlite3_stmt *stmt = NULL;
@@ -9855,59 +10121,74 @@ int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **ke
return CBM_STORE_ERR;
}
/* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */
int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5;
/* A fixed 5x prefilter is normally ample, but it is not a proof: a later
* candidate can have a lower first-keyword score and a much higher
* all-keyword min-score. Expand until either the scan is exhausted or the
* Kth final score is strictly above the best possible omitted score. This
* makes top-K prefixes identical for every requested K, which semantic
* offset pagination requires. */
int requested_limit = limit > 0 ? limit : CBM_SZ_16;
int fetch_limit = requested_limit > INT_MAX / ST_COL_5 ? INT_MAX : requested_limit * ST_COL_5;
sqlite3_bind_blob(stmt, SKIP_ONE, kw_vecs[0], VS_VEC_DIM, SQLITE_STATIC);
sqlite3_bind_text(stmt, ST_COL_2, project, SQLITE_AUTO_LEN, SQLITE_STATIC);
sqlite3_bind_int(stmt, ST_COL_3, fetch_limit);
cbm_vector_result_t *results = NULL;
int count = 0;
for (;;) {
int cap = 0;
int step_rc;
double omitted_score_ceiling = 0.0;
sqlite3_bind_int(stmt, ST_COL_3, fetch_limit);
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
omitted_score_ceiling = sqlite3_column_double(stmt, ST_COL_5);
cbm_vector_result_t *grown =
vs_append_result(results, &count, &cap, stmt, kw_vecs, actual_kw);
if (!grown) {
step_rc = SQLITE_NOMEM;
break;
}
results = grown;
}
if (step_rc != SQLITE_DONE) {
char rc_buf[VS_STR_BUF];
snprintf(rc_buf, sizeof(rc_buf), "%d", step_rc);
cbm_log_warn("vector_search.step_error", "rc", rc_buf, "msg", sqlite3_errmsg(s->db));
cbm_store_free_vector_results(results, count);
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
if (count > 1) {
qsort(results, (size_t)count, sizeof(*results), vs_ranked_result_cmp);
}
bool scan_exhausted = count < fetch_limit;
bool top_k_certified = scan_exhausted || count < requested_limit ||
results[requested_limit - 1].score > omitted_score_ceiling;
if (top_k_certified || fetch_limit == INT_MAX) {
break;
}
cbm_store_free_vector_results(results, count);
results = NULL;
count = 0;
fetch_limit = fetch_limit > INT_MAX / ST_COL_2 ? INT_MAX : fetch_limit * ST_COL_2;
sqlite3_reset(stmt);
}
{
char kw_buf[VS_STR_BUF];
char fl_buf[VS_STR_BUF];
char cnt_buf[VS_STR_BUF];
snprintf(kw_buf, sizeof(kw_buf), "%d", actual_kw);
snprintf(fl_buf, sizeof(fl_buf), "%d", fetch_limit);
cbm_log_info("vector_search.exec", "kw_count", kw_buf, "fetch_limit", fl_buf, "project",
project);
}
cbm_vector_result_t *results = NULL;
int count = 0;
int cap = 0;
int step_rc = 0;
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
cbm_vector_result_t *grown =
vs_append_result(results, &count, &cap, stmt, kw_vecs, actual_kw);
if (!grown) {
break;
}
results = grown;
}
if (step_rc != SQLITE_DONE) {
char rc_buf[VS_STR_BUF];
snprintf(rc_buf, sizeof(rc_buf), "%d", step_rc);
cbm_log_warn("vector_search.step_error", "rc", rc_buf, "msg", sqlite3_errmsg(s->db));
}
{
char cnt_buf[VS_STR_BUF];
snprintf(cnt_buf, sizeof(cnt_buf), "%d", count);
cbm_log_info("vector_search.done", "candidates", cnt_buf);
cbm_log_info("vector_search.done", "kw_count", kw_buf, "fetch_limit", fl_buf, "candidates",
cnt_buf);
}
sqlite3_finalize(stmt);
/* Re-sort by min-score (SQL sorted by first keyword only) */
for (int i = 0; i < count - SKIP_ONE; i++) {
for (int j = i + SKIP_ONE; j < count; j++) {
if (results[j].score > results[i].score) {
cbm_vector_result_t tmp = results[i];
results[i] = results[j];
results[j] = tmp;
}
}
}
/* Trim to requested limit */
int final_limit = limit > 0 ? limit : CBM_SZ_16;
int final_limit = requested_limit;
if (count > final_limit) {
for (int i = final_limit; i < count; i++) {
free(results[i].name);
+22 -5
View File
@@ -108,8 +108,8 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const
/* Get CALLS degree of a node (inbound and outbound). */
void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg);
/* Get distinct file paths for a project. Caller must free each out[i] and out itself.
* Returns CBM_STORE_OK or CBM_STORE_ERR. */
/* Get distinct canonical File-node paths, with a non-Folder node fallback for legacy/manual
* stores that have no File nodes. Caller frees each out[i] and out itself. */
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count);
/* Persisted index-format identity. Bump when a change alters the QN scheme
@@ -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 ───────────────────────────────────────── */
@@ -442,9 +444,17 @@ int cbm_store_checkpoint(cbm_store_t *s);
* connection, in bytes; -1 = unlimited (SQLite default / pre-fix). */
int64_t cbm_store_journal_size_limit(cbm_store_t *s);
/* Advance the pagination-cursor generation atomically. Seeds a genuinely
* legacy database with a fresh per-file uid, preserves that uid thereafter,
* and increments its canonical uint64 mutation counter. Safe inside an
* existing transaction (implemented with a nested savepoint); malformed or
* partial metadata fails closed without committing a partial advance. */
int cbm_store_generation_advance(cbm_store_t *s);
/* Opaque store generation for pagination-cursor staleness detection:
* "u<db_uid>g<mutation_gen>" — db_uid is minted per DB file, mutation_gen
* bumps on every index run. "legacy" for DBs predating store_meta. */
* "u<16-lower-hex-db_uid>g<canonical-uint64-mutation_gen>". Returns "legacy"
* only when store_meta is genuinely absent; malformed or missing metadata is
* an error. */
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz);
/* Seal a fully-written staging database before atomic publication.
@@ -710,6 +720,13 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
int cbm_store_bfs_trail(cbm_store_t *s, int64_t start_id, const char *direction,
const char **edge_types, int edge_type_count, int max_depth,
int max_results, cbm_traverse_result_t *out);
/* BFS with an explicit edge-data budget. max_edges=0 skips the secondary
* all-pairs edge lookup; max_edges>0 collects at most that many edges and
* raises out->edges_truncated on saturation. Node traversal is unchanged.
* This is intended for lean callers that need nodes but not edge properties. */
int cbm_store_bfs_with_edge_limit(cbm_store_t *s, int64_t start_id, const char *direction,
const char **edge_types, int edge_type_count, int max_depth,
int max_results, int max_edges, cbm_traverse_result_t *out);
/* Multi-source BFS from ALL seed ids at once (one CTE, temp-table anchored).
* Seeds are EXCLUDED from the result (impact semantics); MIN(hop) across the
+2 -1
View File
@@ -1775,7 +1775,8 @@ static bool rpc_is_allowed_for_ui(const char *body, size_t body_len) {
const char *name_text = yyjson_is_str(name) ? yyjson_get_str(name) : NULL;
bool allowed =
method_text && strcmp(method_text, "tools/call") == 0 && name_text &&
(strcmp(name_text, "list_projects") == 0 || strcmp(name_text, "get_code_snippet") == 0);
(strcmp(name_text, "list_projects") == 0 || strcmp(name_text, "get_graph_schema") == 0 ||
strcmp(name_text, "get_code_snippet") == 0);
yyjson_doc_free(document);
return allowed;
}
+4 -5
View File
@@ -45,11 +45,10 @@ project_name() { printf '%s' "$1" | sed 's#^/##; s#[^A-Za-z0-9._-]#-#g'; }
# Run query_graph and print the first integer in the JSON response (or -1).
query_count() {
local proj="$1" cypher="$2" resp
resp="$("$BIN" cli query_graph "{\"project\":\"$proj\",\"query\":\"$cypher\"}" 2>/dev/null)"
printf '%s' "$resp" | "$PY" -c 'import sys,re
s=sys.stdin.read()
m=re.findall(r"-?\d+", s)
print(m[0] if m else -1)' 2>/dev/null || echo -1
resp="$("$BIN" cli query_graph "{\"project\":\"$proj\",\"query\":\"$cypher\",\"format\":\"json\"}" 2>/dev/null)"
printf '%s' "$resp" | "$PY" -c 'import json,sys
d=json.load(sys.stdin); rows=d.get("rows", [])
print(rows[0][0] if rows and rows[0] else -1)' 2>/dev/null || echo -1
}
assert_lang() {
+416 -55
View File
@@ -21,6 +21,7 @@
#include <daemon/runtime.h>
#include <daemon/version_cohort.h>
#include <foundation/constants.h>
#include <foundation/log.h>
#include <foundation/platform.h>
#include <mcp/mcp.h>
#include <pipeline/pipeline.h>
@@ -57,10 +58,138 @@ int cbm_cli_build_yaml_stdio_mcp_block_for_test(const char *binary_path, bool go
char *block, size_t block_size);
bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str);
/* CLI config lifecycle tests exercise the real activation guard on POSIX. Keep
* that protocol coverage, but bind it to this suite's private endpoint so an
* editor using the developer's installed CBM cannot make an unrelated cleanup
* assertion fail. One focused activation-order test temporarily overrides this
* parent and restores it before the remaining suite continues. */
static char g_cli_suite_runtime_parent[512];
TEST(cli_suite_uses_private_activation_runtime) {
const char *runtime_parent = cbm_cli_activation_runtime_parent_for_test();
ASSERT_NOT_NULL(runtime_parent);
ASSERT_STR_EQ(runtime_parent, g_cli_suite_runtime_parent);
PASS();
}
TEST(cli_progress_visibility_policy) {
ASSERT_TRUE(cbm_cli_progress_enabled(true, false));
ASSERT_TRUE(cbm_cli_progress_enabled(false, true));
ASSERT_FALSE(cbm_cli_progress_enabled(false, false));
ASSERT_TRUE(cbm_cli_progress_enabled(true, false, false));
ASSERT_TRUE(cbm_cli_progress_enabled(false, false, true));
ASSERT_FALSE(cbm_cli_progress_enabled(false, false, false));
ASSERT_FALSE(cbm_cli_progress_enabled(false, true, true));
ASSERT_FALSE(cbm_cli_progress_enabled(true, true, true));
PASS();
}
TEST(cli_quiet_is_stripped_before_tool_argument_forwarding) {
char *argv[] = {"search_graph", "--project", "demo", "--quiet"};
int argc = (int)(sizeof(argv) / sizeof(argv[0]));
cbm_cli_output_flags_t flags;
char error[256] = {0};
ASSERT_TRUE(cbm_cli_output_flags_parse(&argc, argv, &flags, error, sizeof(error)));
ASSERT_TRUE(flags.quiet_requested);
ASSERT_FALSE(flags.progress_requested);
ASSERT_FALSE(flags.verbose_requested);
ASSERT_EQ(argc, 3);
ASSERT_STR_EQ(argv[0], "search_graph");
ASSERT_STR_EQ(argv[1], "--project");
ASSERT_STR_EQ(argv[2], "demo");
char *build_error = NULL;
char *json = cbm_cli_build_args_json(argv[0], argc - 1, argv + 1, &build_error);
ASSERT_NOT_NULL(json);
ASSERT_NULL(build_error);
ASSERT_NOT_NULL(strstr(json, "\"project\":\"demo\""));
ASSERT_NULL(strstr(json, "quiet"));
free(json);
PASS();
}
TEST(cli_quiet_rejects_other_outer_output_modes_with_guidance) {
char *progress_argv[] = {"--quiet", "--progress", "search_graph"};
int progress_argc = (int)(sizeof(progress_argv) / sizeof(progress_argv[0]));
cbm_cli_output_flags_t progress_flags;
char progress_error[256] = {0};
ASSERT_FALSE(cbm_cli_output_flags_parse(&progress_argc, progress_argv, &progress_flags,
progress_error, sizeof(progress_error)));
ASSERT_NOT_NULL(strstr(progress_error, "--quiet"));
ASSERT_NOT_NULL(strstr(progress_error, "--progress"));
ASSERT_NOT_NULL(strstr(progress_error, "errors-only"));
char *verbose_argv[] = {"--quiet", "--verbose", "index_status"};
int verbose_argc = (int)(sizeof(verbose_argv) / sizeof(verbose_argv[0]));
cbm_cli_output_flags_t verbose_flags;
char verbose_error[256] = {0};
ASSERT_FALSE(cbm_cli_output_flags_parse(&verbose_argc, verbose_argv, &verbose_flags,
verbose_error, sizeof(verbose_error)));
ASSERT_NOT_NULL(strstr(verbose_error, "--verbose"));
ASSERT_NOT_NULL(strstr(verbose_error, "informational diagnostics"));
/* Existing progress plus verbose behavior remains valid; only quiet is
* exclusive with the two output-expanding controls. */
char *compatible_argv[] = {"--progress", "--verbose", "search_graph"};
int compatible_argc = (int)(sizeof(compatible_argv) / sizeof(compatible_argv[0]));
cbm_cli_output_flags_t compatible_flags;
char compatible_error[256] = {0};
ASSERT_TRUE(cbm_cli_output_flags_parse(&compatible_argc, compatible_argv, &compatible_flags,
compatible_error, sizeof(compatible_error)));
ASSERT_TRUE(compatible_flags.progress_requested);
ASSERT_TRUE(compatible_flags.verbose_requested);
ASSERT_FALSE(compatible_flags.quiet_requested);
ASSERT_EQ(compatible_argc, 1);
ASSERT_STR_EQ(compatible_argv[0], "search_graph");
PASS();
}
TEST(cli_quiet_preserves_tool_level_verbose_argument) {
char *argv[] = {"--quiet", "index_status", "--verbose"};
int argc = (int)(sizeof(argv) / sizeof(argv[0]));
cbm_cli_output_flags_t flags;
char error[256] = {0};
ASSERT_TRUE(cbm_cli_output_flags_parse(&argc, argv, &flags, error, sizeof(error)));
ASSERT_TRUE(flags.quiet_requested);
ASSERT_FALSE(flags.verbose_requested);
ASSERT_EQ(argc, 2);
ASSERT_STR_EQ(argv[0], "index_status");
ASSERT_STR_EQ(argv[1], "--verbose");
PASS();
}
static FILE *s_cli_quiet_log_capture;
static void cli_quiet_log_capture(const char *line) {
if (s_cli_quiet_log_capture && line) {
(void)fprintf(s_cli_quiet_log_capture, "%s\n", line);
}
}
TEST(cli_quiet_suppresses_ordinary_diagnostics_but_preserves_errors) {
FILE *out = tmpfile();
ASSERT_NOT_NULL(out);
CBMLogLevel previous_level = cbm_log_get_level();
CBMLogFormat previous_format = cbm_log_get_format();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
s_cli_quiet_log_capture = out;
cbm_log_set_sink(cli_quiet_log_capture);
cbm_cli_diagnostics_configure(true, false);
cbm_log_info("quiet.info", "detail", "drop");
cbm_log_warn("quiet.warning", "detail", "drop");
cbm_log_error("quiet.error", "detail", "keep");
cbm_log_set_sink(NULL);
s_cli_quiet_log_capture = NULL;
cbm_log_set_level(previous_level);
cbm_log_set_format(previous_format);
ASSERT_EQ(fseek(out, 0, SEEK_SET), 0);
char rendered[512] = {0};
size_t rendered_size = fread(rendered, 1, sizeof(rendered) - 1, out);
(void)fclose(out);
ASSERT_TRUE(rendered_size > 0);
ASSERT_STR_EQ(rendered, "level=error msg=quiet.error detail=keep\n");
PASS();
}
@@ -103,6 +232,82 @@ TEST(cli_progress_sink_accepts_worker_json_logs) {
PASS();
}
TEST(cli_progress_sink_enables_lifecycle_info_then_restores_quiet_default) {
CBMLogLevel saved = cbm_log_get_level();
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)"));
cbm_log_set_level(saved);
PASS();
}
TEST(cli_progress_sink_suppresses_noise_and_preserves_failures) {
/* The quiet frontend default is a process policy; pin it for this test
* and hand back whatever the process had. */
CBMLogLevel saved = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_WARN);
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");
cbm_log_set_level(saved);
PASS();
}
TEST(cli_progress_sink_preserves_explicit_verbose_diagnostics) {
FILE *out = tmpfile();
ASSERT_NOT_NULL(out);
CBMLogLevel previous_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_progress_sink_init(out);
cbm_log_info("cli.daemon.spawned", "hint", "keep-info");
cbm_log_debug("cli.detail", "value", "keep-debug");
cbm_progress_sink_fini();
cbm_log_set_level(previous_level);
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_NOT_NULL(strstr(rendered, "msg=cli.daemon.spawned"));
ASSERT_NOT_NULL(strstr(rendered, "msg=cli.detail"));
PASS();
}
enum { CLI_PROGRESS_RACE_THREADS = 4, CLI_PROGRESS_RACE_ROUNDS = 32 };
typedef struct {
@@ -1102,7 +1307,7 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) {
snprintf(dir_arg, sizeof(dir_arg), "--dir=%s", install_dir);
char *install_argv[] = {"--force", "--skip-config", "--yes", dir_arg};
int install_rc = child_ready ? cli_test_cmd_install(4, install_argv) : -1;
cbm_cli_set_activation_runtime_parent_for_test(NULL);
cbm_cli_set_activation_runtime_parent_for_test(g_cli_suite_runtime_parent);
cbm_set_auto_answer_for_test(0);
int child_status = 0;
@@ -7085,8 +7290,7 @@ TEST(cli_registry_routes_omp_via_profile_and_pi_coding_agent_dir) {
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {
"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR",
"XDG_CONFIG_HOME", "APPDATA",
"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME", "APPDATA",
};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
@@ -7171,8 +7375,7 @@ TEST(cli_registry_routes_omp_via_profile_and_pi_coding_agent_dir) {
relocated_plan && strstr(relocated_plan, "\"omp\"") &&
test_json_string_array_contains(relocated_root, "config_files_planned",
omp_relocated_mcp) &&
test_json_string_array_contains(relocated_root, "agent_files_planned",
omp_relocated_agent);
test_json_string_array_contains(relocated_root, "agent_files_planned", omp_relocated_agent);
yyjson_doc_free(relocated_doc);
free(relocated_plan);
cbm_unsetenv("PI_CODING_AGENT_DIR");
@@ -7195,12 +7398,9 @@ TEST(cli_registry_routes_omp_via_profile_and_pi_coding_agent_dir) {
/* None of the plan-only invocations may have written OMP-owned content. */
struct stat state;
bool plan_did_not_mutate =
(stat(omp_default_mcp, &state) != 0) &&
(stat(omp_default_agent, &state) != 0) &&
(stat(omp_profile_mcp, &state) != 0) &&
(stat(omp_profile_agent, &state) != 0) &&
(stat(omp_relocated_mcp, &state) != 0) &&
(stat(omp_relocated_agent, &state) != 0);
(stat(omp_default_mcp, &state) != 0) && (stat(omp_default_agent, &state) != 0) &&
(stat(omp_profile_mcp, &state) != 0) && (stat(omp_profile_agent, &state) != 0) &&
(stat(omp_relocated_mcp, &state) != 0) && (stat(omp_relocated_agent, &state) != 0);
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
@@ -7208,8 +7408,7 @@ TEST(cli_registry_routes_omp_via_profile_and_pi_coding_agent_dir) {
test_rmdir_r(tmpdir);
if (!default_plan_ok || !profile_plan_ok || !relocated_plan_ok || !fallback_plan_ok ||
!plan_did_not_mutate) {
fprintf(stderr,
"omp diag default=%d profile=%d relocated=%d fallback=%d plan_clean=%d\n",
fprintf(stderr, "omp diag default=%d profile=%d relocated=%d fallback=%d plan_clean=%d\n",
default_plan_ok, profile_plan_ok, relocated_plan_ok, fallback_plan_ok,
plan_did_not_mutate);
FAIL("OMP must resolve ~/.omp/agent under the documented fallback, honor OMP_PROFILE "
@@ -7219,15 +7418,14 @@ TEST(cli_registry_routes_omp_via_profile_and_pi_coding_agent_dir) {
PASS();
}
TEST(cli_registry_omp_named_profile_install_and_uninstall_preserve_user_content) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-registry-omp-lifecycle-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR",
"XDG_CONFIG_HOME", "APPDATA"};
const char *const env_names[] = {
"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME", "APPDATA"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
@@ -7268,22 +7466,22 @@ TEST(cli_registry_omp_named_profile_install_and_uninstall_preserve_user_content)
install_rc == 0 && installed_mcp && strstr(installed_mcp, "codebase-memory-mcp") &&
strstr(installed_mcp, binary_path) && installed_instructions &&
strcmp(installed_instructions, user_instructions) == 0 &&
test_file_contains_all(skill_path,
(const char *const[]){"search_graph", "trace_path",
"Sessions and Subagents"},
3U) &&
test_file_contains_all(
skill_path,
(const char *const[]){"search_graph", "trace_path", "Sessions and Subagents"}, 3U) &&
test_file_contains_all(scout_path,
(const char *const[]){"autoloadSkills: [codebase-memory]",
"mcp__codebase_memory_mcp_search_graph"},
"mcp__codebase_memory_mcp_search_graph"},
2U) &&
test_file_contains_all(verify_path,
(const char *const[]){"read-summarize: false",
"mcp__codebase_memory_mcp_trace_path"},
2U) &&
test_file_contains_all(auditor_path,
(const char *const[]){"autoloadSkills: [codebase-memory]",
"mcp__codebase_memory_mcp_check_index_coverage"},
2U);
test_file_contains_all(
verify_path,
(const char *const[]){"read-summarize: false", "mcp__codebase_memory_mcp_trace_path"},
2U) &&
test_file_contains_all(
auditor_path,
(const char *const[]){"autoloadSkills: [codebase-memory]",
"mcp__codebase_memory_mcp_check_index_coverage"},
2U);
free(installed_mcp);
free(installed_instructions);
@@ -7296,12 +7494,12 @@ TEST(cli_registry_omp_named_profile_install_and_uninstall_preserve_user_content)
char *preserved_instructions = read_test_file_alloc(instructions_path);
char *preserved_verify = read_test_file_alloc(verify_path);
char *mcp_after = read_test_file_alloc(mcp_path);
bool uninstalled =
uninstall_rc == 0 && preserved_instructions &&
strcmp(preserved_instructions, user_instructions) == 0 && preserved_verify &&
strcmp(preserved_verify, modified_verify) == 0 && stat(skill_path, &state) != 0 &&
stat(scout_path, &state) != 0 && stat(auditor_path, &state) != 0 &&
(!mcp_after || !strstr(mcp_after, "codebase-memory-mcp"));
bool uninstalled = uninstall_rc == 0 && preserved_instructions &&
strcmp(preserved_instructions, user_instructions) == 0 && preserved_verify &&
strcmp(preserved_verify, modified_verify) == 0 &&
stat(skill_path, &state) != 0 && stat(scout_path, &state) != 0 &&
stat(auditor_path, &state) != 0 &&
(!mcp_after || !strstr(mcp_after, "codebase-memory-mcp"));
free(preserved_instructions);
free(preserved_verify);
free(mcp_after);
@@ -7324,8 +7522,8 @@ TEST(cli_registry_omp_relocated_dry_run_is_non_mutating) {
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR",
"XDG_CONFIG_HOME", "APPDATA"};
const char *const env_names[] = {
"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME", "APPDATA"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
@@ -8810,6 +9008,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");
@@ -10046,9 +10295,10 @@ TEST(cli_claude_hook_commands_use_exec_form_with_custom_config_dir) {
yyjson_doc *document = settings ? yyjson_read(settings, strlen(settings), 0) : NULL;
yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL;
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
size_t owned = test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") +
test_count_exec_hook(root, "PostToolUse", "Read", binary, "hook-augment") +
test_count_exec_hook(root, "SubagentStart", "*", binary, "hook-augment");
size_t owned =
test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") +
test_count_exec_hook(root, "PostToolUse", "Read", binary, "hook-augment") +
test_count_exec_hook(root, "SubagentStart", "*", binary, "hook-augment");
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary, "hook-augment");
}
@@ -10398,6 +10648,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 {
@@ -10914,7 +11213,8 @@ TEST(cli_upgrade_migrates_released_claude_hook_scripts) {
yyjson_val *settings_root = settings_doc ? yyjson_doc_get_root(settings_doc) : NULL;
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
size_t registered =
test_count_exec_hook(settings_root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") +
test_count_exec_hook(settings_root, "PreToolUse", "Grep|Glob|Bash", binary,
"hook-augment") +
test_count_exec_hook(settings_root, "PostToolUse", "Read", binary, "hook-augment") +
test_count_exec_hook(settings_root, "SubagentStart", "*", binary, "hook-augment");
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
@@ -12472,7 +12772,8 @@ TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733) {
yyjson_doc *doc = settings ? yyjson_read(settings, strlen(settings), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
size_t owned =
test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary_path, "hook-augment") +
test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary_path,
"hook-augment") +
test_count_exec_hook(root, "PostToolUse", "Read", binary_path, "hook-augment") +
test_count_exec_hook(root, "SubagentStart", "*", binary_path, "hook-augment");
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
@@ -12537,8 +12838,7 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) {
size_t before_owned =
test_count_exec_hook(installed_root, "PreToolUse", "Grep|Glob|Bash", binary_path,
"hook-augment") +
test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path,
"hook-augment") +
test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path, "hook-augment") +
test_count_exec_hook(installed_root, "SubagentStart", "*", binary_path, "hook-augment");
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
before_owned += test_count_exec_hook(installed_root, "SessionStart", matchers[i],
@@ -12555,10 +12855,9 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) {
yyjson_doc *uninstalled_doc =
uninstalled ? yyjson_read(uninstalled, strlen(uninstalled), 0) : NULL;
yyjson_val *uninstalled_root = uninstalled_doc ? yyjson_doc_get_root(uninstalled_doc) : NULL;
size_t after_owned =
test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\"");
size_t foreign_count = test_count_exec_hook(uninstalled_root, "SessionStart", "resume",
"foreign-hook", "keep");
size_t after_owned = test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\"");
size_t foreign_count =
test_count_exec_hook(uninstalled_root, "SessionStart", "resume", "foreign-hook", "keep");
yyjson_doc_free(uninstalled_doc);
free(uninstalled);
@@ -12568,7 +12867,8 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) {
test_rmdir_r(tmpdir);
if (install_rc != 0 || before_owned != 7U || uninstall_rc != 0 || after_owned != 0U ||
foreign_count != 1U)
FAIL("custom --dir uninstall must remove seven exact-owned exec hooks and keep foreign hooks");
FAIL("custom --dir uninstall must remove seven exact-owned exec hooks and keep foreign "
"hooks");
PASS();
}
@@ -13491,10 +13791,49 @@ TEST(cli_build_args_json_bad_positional_errors_issue680) {
PASS();
}
/* Per-tool --help returns 0 for a known tool, -1 for an unknown one. */
/* Capture per-tool help using the same tmpfile+dup2 idiom as config-command
* output above. This stays portable to the MinGW test leg. */
static int cli_tool_help_capture(const char *tool_name, char *out, size_t cap) {
out[0] = '\0';
FILE *capture = tmpfile();
int saved = capture ? dup(fileno(stdout)) : -1;
if (!capture || saved < 0) {
if (capture)
fclose(capture);
if (saved >= 0)
close(saved);
return -1000;
}
fflush(stdout);
if (dup2(fileno(capture), fileno(stdout)) < 0) {
fclose(capture);
close(saved);
return -1000;
}
int rc = cbm_cli_print_tool_help(tool_name);
fflush(stdout);
(void)dup2(saved, fileno(stdout));
close(saved);
rewind(capture);
size_t got = fread(out, 1, cap - 1, capture);
out[got] = '\0';
fclose(capture);
return rc;
}
/* Per-tool --help returns 0 for a known tool, -1 for an unknown one, and
* surfaces schema choices/defaults rather than flattening every flag to its
* primitive JSON type. */
TEST(cli_print_tool_help_issue680) {
ASSERT_EQ(cbm_cli_print_tool_help("index_repository"), 0);
ASSERT_EQ(cbm_cli_print_tool_help("nope_not_a_tool"), -1);
char out[16 * 1024];
ASSERT_EQ(cli_tool_help_capture("search_graph", out, sizeof(out)), 0);
ASSERT_NOT_NULL(strstr(out, "--format <tree|json> [default: tree]"));
ASSERT_NOT_NULL(strstr(out, "--detail <ids|default> [default: default]"));
ASSERT_EQ(cli_tool_help_capture("index_repository", out, sizeof(out)), 0);
ASSERT_NOT_NULL(strstr(out, "--mode <full|moderate|fast|cross-repo-intelligence> [default: "
"full]"));
ASSERT_EQ(cli_tool_help_capture("nope_not_a_tool", out, sizeof(out)), -1);
ASSERT_STR_EQ(out, "");
PASS();
}
@@ -14009,15 +14348,32 @@ TEST(cli_update_only_names_an_installer_that_exists_issue1632) {
}
SUITE(cli) {
if (!th_secure_runtime_parent_new(g_cli_suite_runtime_parent,
sizeof(g_cli_suite_runtime_parent), "cli-suite")) {
printf(" %sFAIL%s: could not create private CLI activation runtime\n", tf_red(),
tf_reset());
tf_fail_count++;
return;
}
cbm_cli_set_activation_runtime_parent_for_test(g_cli_suite_runtime_parent);
RUN_TEST(cli_suite_uses_private_activation_runtime);
RUN_TEST(cli_update_only_names_an_installer_that_exists_issue1632);
#ifndef _WIN32
RUN_TEST(cli_hook_deadline_ignores_an_unreadable_value);
#endif
RUN_TEST(cli_index_restart_cap_honours_zero_and_refuses_junk);
RUN_TEST(cli_progress_visibility_policy);
RUN_TEST(cli_quiet_is_stripped_before_tool_argument_forwarding);
RUN_TEST(cli_quiet_rejects_other_outer_output_modes_with_guidance);
RUN_TEST(cli_quiet_preserves_tool_level_verbose_argument);
RUN_TEST(cli_quiet_suppresses_ordinary_diagnostics_but_preserves_errors);
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_preserves_explicit_verbose_diagnostics);
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 +14599,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 +14631,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);
@@ -14403,4 +14761,7 @@ SUITE(cli) {
/* Stdin argument gate (#1359) */
RUN_TEST(cli_zero_argument_tool_never_reads_stdin_issue1359);
RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_issue1359);
cbm_cli_set_activation_runtime_parent_for_test(NULL);
test_rmdir_r(g_cli_suite_runtime_parent);
g_cli_suite_runtime_parent[0] = '\0';
}
+150 -5
View File
@@ -810,6 +810,9 @@ TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match) {
/* The discriminator: C has 5 callees, so claiming it has none is a
* fabrication. Pre-fix this is exactly what the saturated path emitted. */
ASSERT_FALSE(saw_c);
/* Relationship expansion materializes every matched row (a cap here once
* falsified aggregates), so nothing was left unevaluated. */
ASSERT_FALSE(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);
@@ -2143,9 +2151,10 @@ TEST(cypher_optional_match_target_still_allowed_issue1919) {
TEST(cypher_with_alias_stays_in_scope_issue1919) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller, g AS callee RETURN caller, callee.name",
"test", 0, &r);
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller, g AS "
"callee RETURN caller, callee.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_TRUE(r.row_count > 0);
@@ -2786,6 +2795,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 +2803,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 +2811,143 @@ 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 MATCH scans every candidate before aggregation: the count is
* exact for any max_rows, and no internal ceiling is reported (a bounded scan
* here once made count(*) depend on the requested row budget). */
TEST(cypher_result_unlabeled_scan_is_exhaustive_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], "11");
ASSERT_FALSE(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
* ══════════════════════════════════════════════════════════════════ */
@@ -3036,8 +3178,8 @@ TEST(cypher_wide_with_refused_not_truncated) {
char query[1024];
int off = snprintf(query, sizeof(query), "MATCH (f:Function) WITH ");
for (int i = 0; i < 20; i++) { /* 20 > CYP_MAX_VARS (16) */
off += snprintf(query + off, sizeof(query) - (size_t)off, "%sf.name AS c%d", i ? ", " : "",
i);
off +=
snprintf(query + off, sizeof(query) - (size_t)off, "%sf.name AS c%d", i ? ", " : "", i);
}
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN *");
@@ -4360,6 +4502,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_unlabeled_scan_is_exhaustive_before_aggregation);
RUN_TEST(cypher_result_reports_variable_length_candidate_saturation);
/* Phase 1: Simple operators */
RUN_TEST(cypher_lex_neq_operators);
RUN_TEST(cypher_lex_ends_keyword);
+3
View File
@@ -2079,6 +2079,8 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) {
bool second_interrupted = false;
bool exited = false;
atomic_store_explicit(&runtime_activation_shutdown_log_seen, false, memory_order_release);
CBMLogLevel previous_log_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_sink(runtime_test_activation_shutdown_sink);
if (started) {
@@ -2095,6 +2097,7 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) {
second_interrupted = !cbm_daemon_runtime_client_heartbeat(second, RUNTIME_TEST_TIMEOUT_MS);
}
cbm_log_set_sink(NULL);
cbm_log_set_level(previous_log_level);
if (first) {
(void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS);
first = NULL;
+2 -1
View File
@@ -360,6 +360,7 @@ static bool diagnostics_discovery_value(const char *key, char *out, size_t out_s
TEST(diagnostics_discovery_record_survives_suppressed_log_level) {
diagnostics_discovery_records = 0;
diagnostics_discovery_line[0] = '\0';
CBMLogLevel previous_log_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_NONE);
cbm_log_set_sink_ex(diagnostics_discovery_sink, CBM_LOG_SINK_REPLACE);
ASSERT_EQ(cbm_setenv("CBM_DIAGNOSTICS", "1", 1), 0);
@@ -369,7 +370,7 @@ TEST(diagnostics_discovery_record_survives_suppressed_log_level) {
cbm_diag_stop();
(void)cbm_unsetenv("CBM_DIAGNOSTICS");
cbm_log_set_sink(NULL);
cbm_log_set_level(CBM_LOG_INFO);
cbm_log_set_level(previous_log_level);
char recorded_snapshot[1024] = "";
char recorded_trajectory[1024] = "";
+18 -11
View File
@@ -1172,19 +1172,26 @@ TEST(ui_server_mutations_require_json_content_type) {
TEST(ui_server_rpc_allows_only_ui_read_tools) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}";
char req[1024];
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n\r\n%s",
(int)strlen(body), body);
char resp[8192];
int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
int n = 0;
static const char *allowed_tools[] = {"list_projects", "get_graph_schema", "get_code_snippet"};
for (size_t i = 0; i < sizeof(allowed_tools) / sizeof(allowed_tools[0]); i++) {
char body[512];
snprintf(body, sizeof(body),
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"%s\",\"arguments\":{}}}",
allowed_tools[i]);
snprintf(req, sizeof(req),
"POST /rpc HTTP/1.1\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
}
static const char *blocked_tools[] = {"delete_project", "manage_adr", "ingest_traces",
"index_repository"};
+47 -13
View File
@@ -916,6 +916,16 @@ TEST(incr_perf_single_file_fast) {
}
delete_file_at("fastapi/incr_perf_probe.py");
/* Publish the cleanup too. search_code now consumes the complete scoped
* file list and fails closed when the graph names a source file that grep
* cannot read; leaving this probe indexed would make every later matching
* search correctly report an incomplete snapshot instead of exercising
* the search parameter contract this fixture is meant to cover. The
* cleanup reindex is deliberately outside the measured interval above. */
resp = index_repo();
ASSERT(resp != NULL);
ASSERT(strstr(resp, "indexed") != NULL);
free(resp);
PASS();
}
@@ -1568,26 +1578,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();
}
@@ -1908,12 +1921,22 @@ TEST(tool_arch_no_aspects) {
TEST(tool_detect_changes_default) {
double ms;
char *r = call_tool_timed("detect_changes", &ms, "{\"project\":\"%s\"}", g_project);
/* The fixture is intentionally detached, so pin only the base while
* exercising every other default (scope, direction, depth, and budget). */
char *r = call_tool_timed("detect_changes", &ms,
"{\"project\":\"%s\",\"base_branch\":\"HEAD\"}", g_project);
TOOL_OK(r, ms);
/* New tree contract: base + direction scalars, a changed_files section,
* and the seed/impact accounting. (The old changed_count/impacted_symbols/
* depth keys are gone — impact is now a real traversal, not bare names.) */
ASSERT(strstr(r, "changed_files:") != NULL);
NOT_ERROR(r);
/* Lean tree contract: preserve exact changed-file accounting, but do not
* spend tokens on a zero-row table. A non-empty page still carries the
* changed_files rows; an empty page is represented by its exact scalars. */
int changed_total = count_in_response(r, "changed_total");
int changed_returned = count_in_response(r, "changed_returned");
ASSERT_GTE(changed_total, 0);
ASSERT_GTE(changed_returned, 0);
ASSERT_LTE(changed_returned, changed_total);
ASSERT(changed_returned > 0 ? resp_has_key(r, "changed_files")
: resp_lacks_key(r, "changed_files"));
ASSERT(strstr(r, "direction:") != NULL);
ASSERT(strstr(r, "seed_symbols:") != NULL);
free(r);
@@ -1961,7 +1984,15 @@ TEST(tool_detect_changes_since) {
char *r = call_tool_timed("detect_changes", &ms, "{\"project\":\"%s\",\"since\":\"HEAD\"}",
g_project);
TOOL_OK(r, ms);
ASSERT(resp_has_key(r, "changed_files"));
NOT_ERROR(r);
ASSERT(strstr(r, "base: HEAD") != NULL);
int changed_total = count_in_response(r, "changed_total");
int changed_returned = count_in_response(r, "changed_returned");
ASSERT_GTE(changed_total, 0);
ASSERT_GTE(changed_returned, 0);
ASSERT_LTE(changed_returned, changed_total);
ASSERT(changed_returned > 0 ? resp_has_key(r, "changed_files")
: resp_lacks_key(r, "changed_files"));
free(r);
PASS();
}
@@ -2262,8 +2293,11 @@ TEST(tool_qg_max_rows_1) {
"\"max_rows\":1}",
g_project);
TOOL_OK(r, ms);
int returned = count_in_response(r, "returned");
int total = count_in_response(r, "total");
ASSERT_EQ(total, 1);
ASSERT_EQ(returned, 1);
ASSERT_GT(total, returned);
ASSERT_NOT_NULL(strstr(r, "page_limit"));
free(r);
PASS();
}
+4 -3
View File
@@ -392,7 +392,7 @@ TEST(index_parse_partial_reported) {
ASSERT_TRUE(marked);
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *qresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(qresp);
ASSERT_NOT_NULL(strstr(qresp, "split.c"));
@@ -516,7 +516,7 @@ TEST(index_parse_partial_clears_on_fix) {
/* Flagged after the first (full) index. */
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *cov1 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(cov1);
ASSERT_NOT_NULL(strstr(cov1, "flaky.c"));
@@ -635,7 +635,7 @@ TEST(index_not_indexed_by_design_reported) {
/* index_status carries the persisted by-design section. */
char qargs[900];
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\"}", lp.project);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *sresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(sresp);
ASSERT_NOT_NULL(strstr(sresp, "not_indexed"));
@@ -663,6 +663,7 @@ TEST(index_not_indexed_by_design_reported) {
char *resp2 = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs);
ASSERT_NOT_NULL(resp2);
free(resp2);
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
char *sresp2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
ASSERT_NOT_NULL(sresp2);
ASSERT_NOT_NULL(strstr(sresp2, "secret.py"));
+45
View File
@@ -643,6 +643,50 @@ TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling) {
PASS();
}
TEST(index_supervisor_worker_keeps_default_info_liveness_heartbeat) {
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-heartbeat-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
const char *old_cache = getenv("CBM_CACHE_DIR");
const char *old_log_level = getenv("CBM_LOG_LEVEL");
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
char *saved_log_level = old_log_level ? cbm_strdup(old_log_level) : NULL;
(void)cbm_setenv("CBM_CACHE_DIR", cache, 1);
(void)cbm_unsetenv("CBM_LOG_LEVEL");
cbm_index_worker_handle_t *handle = NULL;
int start_rc = cbm_index_worker_start("{\"__cbm_test_worker\":\"heartbeat\"}", 0, false,
NULL, NULL, &handle);
char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0};
if (handle) {
(void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle));
}
bool ready = log_path[0] && index_supervisor_test_wait_file_text(
log_path, "async worker heartbeat probe ready",
INDEX_SUPERVISOR_TEST_READY_MS);
bool heartbeat = ready && index_supervisor_test_wait_file_text(
log_path, "msg=pipeline.discover", 1000);
const cbm_index_worker_result_t *result = NULL;
bool terminal = handle && index_supervisor_test_poll_terminal(
handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result);
bool clean = terminal && result && result->outcome == CBM_PROC_CLEAN && result->response;
if (terminal) {
cbm_index_worker_destroy(handle);
} else {
index_supervisor_test_dump("heartbeat worker log", log_path);
index_supervisor_test_cleanup_handle(handle);
}
index_supervisor_test_restore_env("CBM_LOG_LEVEL", saved_log_level);
index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache);
(void)th_rmtree(cache);
ASSERT_EQ(start_rc, 0);
ASSERT_TRUE(ready);
ASSERT_TRUE(heartbeat);
ASSERT_TRUE(clean);
PASS();
}
TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) {
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-relay-XXXXXX", cbm_tmpdir());
@@ -854,6 +898,7 @@ SUITE(index_supervisor) {
RUN_TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached);
RUN_TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree);
RUN_TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling);
RUN_TEST(index_supervisor_worker_keeps_default_info_liveness_heartbeat);
RUN_TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback);
RUN_TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained);
RUN_TEST(index_supervisor_killed_worker_log_is_never_empty_and_names_the_run);
+41
View File
@@ -60,6 +60,45 @@ TEST(log_level_default) {
PASS();
}
TEST(log_level_env_keeps_info_and_debug_opt_in) {
CBMLogLevel saved = cbm_log_get_level();
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");
/* Restore what the process had: later suites observe INFO records. */
cbm_log_set_level(saved);
PASS();
}
TEST(log_startup_policy_keeps_frontends_quiet_and_workers_live) {
CBMLogLevel saved = cbm_log_get_level();
cbm_unsetenv("CBM_LOG_LEVEL");
cbm_log_init_for_process(true, false);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN);
cbm_log_init_for_process(false, true);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO);
cbm_setenv("CBM_LOG_LEVEL", "none", 1);
cbm_log_init_for_process(false, true);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_INFO);
cbm_setenv("CBM_LOG_LEVEL", "debug", 1);
cbm_log_init_for_process(true, false);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_DEBUG);
cbm_unsetenv("CBM_LOG_LEVEL");
/* Restore what the process had: later suites observe INFO records. */
cbm_log_set_level(saved);
PASS();
}
TEST(log_level_set) {
cbm_log_set_level(CBM_LOG_WARN);
ASSERT_EQ(cbm_log_get_level(), CBM_LOG_WARN);
@@ -285,6 +324,8 @@ TEST(log_level_from_env_invalid_ignored) {
SUITE(log) {
RUN_TEST(log_level_default);
RUN_TEST(log_level_env_keeps_info_and_debug_opt_in);
RUN_TEST(log_startup_policy_keeps_frontends_quiet_and_workers_live);
RUN_TEST(log_level_set);
RUN_TEST(log_info_output);
RUN_TEST(log_filtered_by_level);
+13
View File
@@ -16,6 +16,7 @@ int tf_skip_count = 0;
#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */
#include "foundation/log.h" /* crash-durable worker log probe */
#include "foundation/mem.h" /* cbm_mem_init — worker budget */
#include "foundation/log.h" /* worker liveness heartbeat probe */
#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */
#include "daemon/runtime.h" /* bounded worker response probe */
#include "daemon/ipc.h" /* Windows private-lock re-exec probe */
@@ -234,6 +235,17 @@ static void tf_index_worker_probe(const char *args_json, const char *response_ou
fflush(NULL);
_Exit(response ? 0 : 1);
}
if (strstr(args_json, "\"heartbeat\"")) {
FILE *response = response_out ? cbm_fopen(response_out, "wb") : NULL;
if (response) {
(void)fputs("{\"probe\":\"heartbeat\"}", response);
(void)fclose(response);
}
cbm_log_info("pipeline.discover", "files", "1");
(void)fprintf(stderr, "async worker heartbeat probe ready\n");
fflush(NULL);
_Exit(response ? 0 : 1);
}
if (strstr(args_json, "\"crash\"")) {
(void)fprintf(stderr, "async worker crash probe\n");
fflush(NULL);
@@ -346,6 +358,7 @@ static int tf_maybe_run_index_worker(int argc, char **argv) {
invocation.marker_file, invocation.quarantine_file,
invocation.memory_budget_bytes);
cbm_mem_init_with_cap(0.5, invocation.memory_budget_bytes);
cbm_log_init_for_process(false, true);
tf_index_worker_probe(invocation.args_json, invocation.response_out);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
+5918 -73
View File
File diff suppressed because it is too large Load Diff
+161 -85
View File
@@ -1057,14 +1057,13 @@ static NamedEdgePropertyObservation observe_named_edge_callee_property(
}
observation.database_opened = true;
static const char sql[] =
"SELECT e.properties, json_valid(e.properties), "
"CASE WHEN json_valid(e.properties) "
"THEN json_extract(e.properties, '$.callee') END "
"FROM edges e "
"JOIN nodes src ON src.id=e.source_id AND src.project=e.project "
"JOIN nodes tgt ON tgt.id=e.target_id AND tgt.project=e.project "
"WHERE e.project=?1 AND e.type=?2 AND src.name=?3 AND tgt.name=?4;";
static const char sql[] = "SELECT e.properties, json_valid(e.properties), "
"CASE WHEN json_valid(e.properties) "
"THEN json_extract(e.properties, '$.callee') END "
"FROM edges e "
"JOIN nodes src ON src.id=e.source_id AND src.project=e.project "
"JOIN nodes tgt ON tgt.id=e.target_id AND tgt.project=e.project "
"WHERE e.project=?1 AND e.type=?2 AND src.name=?3 AND tgt.name=?4;";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK ||
sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
@@ -1752,13 +1751,13 @@ TEST(pipeline_call_reference_sequential_parallel_edge_set_parity) {
long_reference_name[0] = 'l';
long_reference_name[LONG_REFERENCE_NAME_LEN] = '\0';
char long_reference_source[1024];
int long_reference_source_len = snprintf(
long_reference_source, sizeof(long_reference_source),
"package parity\n"
"func %s() {}\n"
"func longPropertiesReferenceAccept(callback func()) {}\n"
"func longPropertiesReferenceSite() { longPropertiesReferenceAccept(%s) }\n",
long_reference_name, long_reference_name);
int long_reference_source_len =
snprintf(long_reference_source, sizeof(long_reference_source),
"package parity\n"
"func %s() {}\n"
"func longPropertiesReferenceAccept(callback func()) {}\n"
"func longPropertiesReferenceSite() { longPropertiesReferenceAccept(%s) }\n",
long_reference_name, long_reference_name);
if (long_reference_source_len <= 0 ||
(size_t)long_reference_source_len >= sizeof(long_reference_source)) {
th_rmtree(tmp);
@@ -1946,18 +1945,18 @@ TEST(pipeline_call_reference_sequential_parallel_edge_set_parity) {
named_edge_count(sequential_store, sequential_project, "CALLS",
shadow_controls[i].source_name, shadow_controls[i].target_name);
}
sequential_long_reference = named_edge_count(
sequential_store, sequential_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
long_reference_name);
sequential_long_usage = named_edge_count(sequential_store, sequential_project, "USAGE",
"longPropertiesReferenceSite",
long_reference_name);
sequential_long_calls = named_edge_count(sequential_store, sequential_project, "CALLS",
"longPropertiesReferenceSite",
long_reference_name);
sequential_long_reference =
named_edge_count(sequential_store, sequential_project, "CALL_REFERENCE",
"longPropertiesReferenceSite", long_reference_name);
sequential_long_usage =
named_edge_count(sequential_store, sequential_project, "USAGE",
"longPropertiesReferenceSite", long_reference_name);
sequential_long_calls =
named_edge_count(sequential_store, sequential_project, "CALLS",
"longPropertiesReferenceSite", long_reference_name);
sequential_long_property = observe_named_edge_callee_property(
sequential_db_path, sequential_project, "CALL_REFERENCE",
"longPropertiesReferenceSite", long_reference_name, long_reference_name);
sequential_db_path, sequential_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
long_reference_name, long_reference_name);
cbm_store_close(sequential_store);
}
cbm_pipeline_free(sequential);
@@ -1995,9 +1994,9 @@ TEST(pipeline_call_reference_sequential_parallel_edge_set_parity) {
named_edge_count(parallel_store, parallel_project, "CALLS",
shadow_controls[i].source_name, shadow_controls[i].target_name);
}
parallel_long_reference = named_edge_count(
parallel_store, parallel_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
long_reference_name);
parallel_long_reference =
named_edge_count(parallel_store, parallel_project, "CALL_REFERENCE",
"longPropertiesReferenceSite", long_reference_name);
parallel_long_usage = named_edge_count(parallel_store, parallel_project, "USAGE",
"longPropertiesReferenceSite", long_reference_name);
parallel_long_calls = named_edge_count(parallel_store, parallel_project, "CALLS",
@@ -2477,8 +2476,8 @@ static void closure_probe_repo(const char *tmp) {
}
/* Fresh full reference build of the same tree into its own DB. */
static void closure_fresh_full(const char *tmp, const char *db_path, int *out_nodes,
int *out_edges, int *out_ref_edges, const char *project_hint) {
static void closure_fresh_full(const char *tmp, const char *db_path, int *out_nodes, int *out_edges,
int *out_ref_edges, const char *project_hint) {
*out_nodes = -1;
*out_edges = -2;
*out_ref_edges = -3;
@@ -2495,9 +2494,9 @@ static void closure_fresh_full(const char *tmp, const char *db_path, int *out_no
if (store) {
*out_nodes = cbm_store_count_nodes(store, project);
*out_edges = cbm_store_count_edges(store, project);
*out_ref_edges = named_edge_to_file_count(store, project, "CALL_REFERENCE",
"closureProbeCaller", "closureProbeHelper",
"lib.ts");
*out_ref_edges =
named_edge_to_file_count(store, project, "CALL_REFERENCE", "closureProbeCaller",
"closureProbeHelper", "lib.ts");
cbm_store_close(store);
}
}
@@ -2582,8 +2581,8 @@ TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) {
ASSERT_NOT_NULL(store);
repaired_nodes = cbm_store_count_nodes(store, project);
repaired_edges = cbm_store_count_edges(store, project);
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE",
"closureProbeCaller", "closureProbeHelper", "lib.ts");
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE", "closureProbeCaller",
"closureProbeHelper", "lib.ts");
cbm_store_close(store);
char full_db[512];
@@ -2639,8 +2638,8 @@ TEST(pipeline_closure_repair_removed_def_drops_dependent_edge) {
int repaired_refs = -1;
cbm_store_t *store = cbm_store_open_path(db);
ASSERT_NOT_NULL(store);
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE",
"closureProbeCaller", "closureProbeHelper", "lib.ts");
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE", "closureProbeCaller",
"closureProbeHelper", "lib.ts");
int repaired_nodes = cbm_store_count_nodes(store, project);
int repaired_edges = cbm_store_count_edges(store, project);
cbm_store_close(store);
@@ -2883,8 +2882,7 @@ TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full) {
* target_a.ts to target_b.ts. Since alias-config governance landed this
* runs as a closure repair, and the convergence assertions below now
* prove that route rather than being satisfied by a full rebuild. */
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(),
CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
const char *incremental_project = cbm_pipeline_project_name(incremental);
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
ASSERT_NOT_NULL(incremental_store);
@@ -2938,6 +2936,87 @@ TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full) {
}
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
static int read_published_generation(const char *db_path, char *out, size_t out_size) {
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (!store) {
return CBM_STORE_ERR;
}
int rc = cbm_store_generation(store, out, out_size);
cbm_store_close(store);
return rc;
}
static bool split_published_generation(const char *generation, char uid[18],
unsigned long long *mutation) {
if (!generation || strlen(generation) < 19 || generation[0] != 'u' || generation[17] != 'g') {
return false;
}
memcpy(uid, generation, 17);
uid[17] = '\0';
char *end = NULL;
unsigned long long parsed = strtoull(generation + 18, &end, 10);
if (!end || end == generation + 18 || *end != '\0') {
return false;
}
*mutation = parsed;
return true;
}
/* Every published database must carry cursor-generation metadata. A complete
* replacement gets a fresh database identity; an isolated delta clones the
* live database and advances only its mutation counter. */
TEST(pipeline_publication_stamps_full_and_delta_generations) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_publish_cursor_generation_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
write_temp_file(tmp, "generation.py", "def PublishedGeneration():\n return 1\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
cbm_pipeline_incremental_test_reset_faults();
cbm_pipeline_t *baseline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
ASSERT_NOT_NULL(baseline);
ASSERT_EQ(cbm_pipeline_run(baseline), 0);
cbm_pipeline_free(baseline);
char first[128];
ASSERT_EQ(read_published_generation(db_path, first, sizeof(first)), CBM_STORE_OK);
cbm_pipeline_incremental_test_reset_faults();
cbm_pipeline_t *replacement = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(replacement);
ASSERT_EQ(cbm_pipeline_run(replacement), 0);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL);
cbm_pipeline_free(replacement);
char second[128];
ASSERT_EQ(read_published_generation(db_path, second, sizeof(second)), CBM_STORE_OK);
write_temp_file(tmp, "generation.py", "def PublishedGeneration():\n return 2\n");
cbm_pipeline_incremental_test_reset_faults();
cbm_pipeline_t *delta = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(delta);
ASSERT_EQ(cbm_pipeline_run(delta), 0);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
cbm_pipeline_free(delta);
char third[128];
ASSERT_EQ(read_published_generation(db_path, third, sizeof(third)), CBM_STORE_OK);
char first_uid[18];
char second_uid[18];
char third_uid[18];
unsigned long long first_mutation = 0;
unsigned long long second_mutation = 0;
unsigned long long third_mutation = 0;
ASSERT_TRUE(split_published_generation(first, first_uid, &first_mutation));
ASSERT_TRUE(split_published_generation(second, second_uid, &second_mutation));
ASSERT_TRUE(split_published_generation(third, third_uid, &third_mutation));
ASSERT_TRUE(strcmp(first_uid, second_uid) != 0);
ASSERT_STR_EQ(second_uid, third_uid);
ASSERT_TRUE(third_mutation > second_mutation);
th_rmtree(tmp);
cbm_pipeline_incremental_test_reset_faults();
PASS();
}
static void observe_named_generation(const char *db_path, const char *project,
const char *before_name, const char *after_name,
int *before_count, int *after_count) {
@@ -3072,8 +3151,8 @@ TEST(pipeline_publication_never_uses_a_predictable_staging_path) {
static const char canary[] = "canary-must-survive\n";
char canary_path[PREDICTABLE_CANARIES][640];
for (int i = 0; i < PREDICTABLE_CANARIES; i++) {
snprintf(canary_path[i], sizeof(canary_path[i]), "%s.stage.%ld.%d", db_path,
(long)getpid(), i + 1);
snprintf(canary_path[i], sizeof(canary_path[i]), "%s.stage.%ld.%d", db_path, (long)getpid(),
i + 1);
ASSERT_EQ(th_write_file(canary_path[i], canary), 0);
}
@@ -6760,7 +6839,7 @@ TEST(pipeline_swift_cross_package_import) {
cbm_edge_t *edges = NULL;
int ec = 0;
ASSERT_EQ(cbm_store_find_edges_by_source_type(s, importer.id, "IMPORTS", &edges, &ec),
CBM_STORE_OK);
CBM_STORE_OK);
bool found_exact_edge = false;
for (int i = 0; i < ec; i++) {
@@ -10269,17 +10348,16 @@ static const char *pkg_entries_entry_for(const cbm_pkg_entries_t *e, const char
* above for the full end-to-end proof. */
TEST(pkgmap_swift_targets_registers_module) {
static const char src[] =
"// swift-tools-version:5.9\n"
"import PackageDescription\n"
"let package = Package(\n"
" name: \"Core\",\n"
" targets: [.target(name: \"Core\", dependencies: [])]\n"
")\n";
static const char src[] = "// swift-tools-version:5.9\n"
"import PackageDescription\n"
"let package = Package(\n"
" name: \"Core\",\n"
" targets: [.target(name: \"Core\", dependencies: [])]\n"
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
(int)strlen(src), &entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
&entries);
ASSERT_TRUE(ok);
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core");
@@ -10300,8 +10378,8 @@ TEST(pkgmap_swift_products_do_not_register_alias) {
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
(int)strlen(src), &entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
&entries);
ASSERT_TRUE(ok);
ASSERT_FALSE(pkg_entries_has_name(&entries, "CoreKit"));
ASSERT_TRUE(pkg_entries_has_name(&entries, "CoreImpl"));
@@ -10320,15 +10398,14 @@ TEST(pkgmap_swift_products_do_not_register_alias) {
* fixture in this file happens to follow `name:` with `dependencies:` or a
* comma, so this specific shape was previously untested and unnoticed. */
TEST(pkgmap_swift_target_name_immediately_before_close_paren) {
static const char src[] =
"let package = Package(\n"
" name: \"Core\",\n"
" targets: [.target(name: \"Core\")]\n"
")\n";
static const char src[] = "let package = Package(\n"
" name: \"Core\",\n"
" targets: [.target(name: \"Core\")]\n"
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
(int)strlen(src), &entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
&entries);
ASSERT_TRUE(ok);
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/Core");
@@ -10346,8 +10423,8 @@ TEST(pkgmap_swift_target_honors_literal_path) {
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
(int)strlen(src), &entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
&entries);
ASSERT_TRUE(ok);
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Vendor/CoreLegacy");
@@ -10361,16 +10438,15 @@ TEST(pkgmap_swift_target_honors_literal_path) {
* target entirely (fail closed), even though its `name:` is a valid
* literal. */
TEST(pkgmap_swift_target_computed_path_fails_closed) {
static const char src[] =
"let customPath = computePath()\n"
"let package = Package(\n"
" name: \"Core\",\n"
" targets: [.target(name: \"Core\", path: customPath)]\n"
")\n";
static const char src[] = "let customPath = computePath()\n"
"let package = Package(\n"
" name: \"Core\",\n"
" targets: [.target(name: \"Core\", path: customPath)]\n"
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
(int)strlen(src), &entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
&entries);
ASSERT_TRUE(ok);
ASSERT_EQ(entries.count, 0);
cbm_pkg_entries_free(&entries);
@@ -10392,8 +10468,8 @@ TEST(pkgmap_swift_target_in_comment_or_string_not_registered) {
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
(int)strlen(src), &entries);
bool ok =
cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, (int)strlen(src), &entries);
ASSERT_TRUE(ok);
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
ASSERT_FALSE(pkg_entries_has_name(&entries, "Decoy"));
@@ -10422,8 +10498,8 @@ TEST(pkgmap_swift_dependencies_do_not_leak_entries) {
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
(int)strlen(src), &entries);
bool ok =
cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, (int)strlen(src), &entries);
ASSERT_TRUE(ok);
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
ASSERT_FALSE(pkg_entries_has_name(&entries, "Core"));
@@ -10438,18 +10514,17 @@ TEST(pkgmap_swift_dependencies_do_not_leak_entries) {
* (Utils/UtilsPkg) name OTHER modules, not this manifest's own
* products/targets, so neither mints an entry. */
TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry) {
static const char src[] =
"let package = Package(\n"
" name: \"App\",\n"
" targets: [.target(name: \"App\", dependencies: [\n"
" \"Core\",\n"
" .product(name: \"Utils\", package: \"UtilsPkg\")\n"
" ])]\n"
")\n";
static const char src[] = "let package = Package(\n"
" name: \"App\",\n"
" targets: [.target(name: \"App\", dependencies: [\n"
" \"Core\",\n"
" .product(name: \"Utils\", package: \"UtilsPkg\")\n"
" ])]\n"
")\n";
cbm_pkg_entries_t entries;
cbm_pkg_entries_init(&entries);
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
(int)strlen(src), &entries);
bool ok =
cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, (int)strlen(src), &entries);
ASSERT_TRUE(ok);
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
ASSERT_FALSE(pkg_entries_has_name(&entries, "Core"));
@@ -13687,6 +13762,7 @@ SUITE(pipeline_semantic_manifest_repro) {
RUN_TEST(pipeline_closure_repair_budget_declines_to_full);
RUN_TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full);
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
RUN_TEST(pipeline_publication_stamps_full_and_delta_generations);
RUN_TEST(pipeline_git_context_change_forces_full_and_refreshes_branch);
RUN_TEST(pipeline_global_extension_config_change_forces_full);
RUN_TEST(pipeline_publication_never_uses_a_predictable_staging_path);
+30
View File
@@ -143,6 +143,35 @@ TEST(store_generation_tracks_mutations) {
PASS();
}
/* Existing store_meta is authoritative: a partially-written or malformed
* table is corruption, not a legacy database. Generation reads and the
* project-upsert mutation choke point must both fail closed, and a failed
* generation advance must not leave the project row committed. */
TEST(store_generation_rejects_malformed_metadata_atomically) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_exec(s, "CREATE TABLE store_meta (k TEXT PRIMARY KEY, v TEXT);"
"INSERT INTO store_meta VALUES('db_uid','0123456789abcdef');"),
CBM_STORE_OK);
char generation[128] = {0};
ASSERT_EQ(cbm_store_generation(s, generation, sizeof(generation)), CBM_STORE_ERR);
ASSERT_EQ(cbm_store_upsert_project(s, "must-not-commit", "/tmp/must-not-commit"),
CBM_STORE_ERR);
cbm_project_t project = {0};
ASSERT_EQ(cbm_store_get_project(s, "must-not-commit", &project), CBM_STORE_NOT_FOUND);
ASSERT_EQ(cbm_store_exec(s, "INSERT INTO store_meta VALUES('mutation_gen','not-a-number');"),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_generation(s, generation, sizeof(generation)), CBM_STORE_ERR);
ASSERT_EQ(cbm_store_exec(s, "UPDATE store_meta SET v=CAST(X'31006a756e6b' AS TEXT) "
"WHERE k='mutation_gen';"),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_generation(s, generation, sizeof(generation)), CBM_STORE_ERR);
cbm_store_close(s);
PASS();
}
/* #896: a row-scan that dies mid-stream (SQLITE_CORRUPT) must surface a
* loud store error, not masquerade as a clean end of results. Counts are
* answered from covering indexes (still correct) while row fetches die at
@@ -236,6 +265,7 @@ TEST(corrupt_page_scan_returns_error_not_truncation) {
SUITE(store_pragmas) {
RUN_TEST(journal_size_limit_bounds_wal_issue1083);
RUN_TEST(store_generation_tracks_mutations);
RUN_TEST(store_generation_rejects_malformed_metadata_atomically);
RUN_TEST(corrupt_page_scan_returns_error_not_truncation);
RUN_TEST(mmap_size_default_when_unset);
RUN_TEST(mmap_size_zero_disables_mmap);
+31 -5
View File
@@ -6,7 +6,8 @@ Guards the PR #1139 daemon-control surface at the product level:
* ``daemon start`` launches a PERMANENT daemon: it reports a pid, and the
daemon survives its clients (a one-shot ``cli`` command recycles it without
printing the cold-start hint, and the daemon is still active afterwards).
* A cold one-shot ``cli`` command (no daemon) prints the startup-tax hint.
* A cold one-shot ``cli`` command is quiet by default; ``cli --verbose`` opts
into the startup-tax hint.
* ``daemon stop`` on an idle daemon stops it; a second ``stop`` is idempotent.
Every path carries a kill-by-pid backstop so a stuck daemon can never hang the
@@ -24,9 +25,22 @@ import sys
import tempfile
def runtime_for(cache):
"""A private daemon rendezvous parent, a sibling of the cache directory."""
head, tail = os.path.split(cache)
runtime = "runtime" + tail[len("cache"):] if tail.startswith("cache") else tail + "-runtime"
path = os.path.join(head, runtime)
os.makedirs(path, exist_ok=True)
return path
def run_cli(binary, cache, args, timeout=60, extra_env=None):
env = dict(os.environ)
env["CBM_CACHE_DIR"] = cache
# Daemon coordination is account-wide and CBM_CACHE_DIR does not move the
# rendezvous; a private CBM_RUNTIME_DIR per cache is what isolates a call
# from every other daemon on the account (and makes a cold call cold).
env["CBM_RUNTIME_DIR"] = runtime_for(cache)
if extra_env:
env.update(extra_env)
return subprocess.run([binary] + args, capture_output=True, timeout=timeout, env=env)
@@ -68,11 +82,23 @@ def main():
cold = run_cli(binary, cache, ["cli", "list_projects", "{}"])
cold_text = output_text(cold)
if cold.returncode != 0 or "daemon start" not in cold_text:
print("RED: a cold one-shot cli command should succeed and hint at "
"`daemon start`:\n%s" % cold_text[:400])
if cold.returncode != 0 or "daemon start" in cold_text:
print("RED: a cold one-shot cli command should succeed without default "
"startup chatter:\n%s" % cold_text[:400])
return 1
print("PASS: cold cli one-shot succeeded and printed the startup-tax hint")
print("PASS: cold cli one-shot succeeded quietly")
# Use an isolated rendezvous namespace to guarantee a cold verbose call.
verbose_cache = os.path.join(work, "cache-verbose")
os.makedirs(verbose_cache, exist_ok=True)
verbose = run_cli(binary, verbose_cache,
["cli", "--verbose", "list_projects", "{}"])
verbose_text = output_text(verbose)
if verbose.returncode != 0 or "daemon start" not in verbose_text:
print("RED: `cli --verbose` should expose the cold-start hint:\n%s"
% verbose_text[:400])
return 1
print("PASS: verbose cold cli exposed the startup-tax hint")
start = run_cli(binary, cache, ["daemon", "start"])
start_text = output_text(start)
+3 -3
View File
@@ -332,9 +332,9 @@ def section_crash_recovery(binary, work):
"(`daemon status` kept reporting it)" % daemon_pid)
return False
cold = run_cli(binary, cache, ["cli", "list_projects", "{}"], timeout=90)
if cold.returncode != 0 or "daemon start" not in out_text(cold):
print("RED: a cold one-shot after the daemon crash should succeed with the "
"startup-tax hint:\n%s" % excerpt(out_text(cold)))
if cold.returncode != 0 or "daemon start" in out_text(cold):
print("RED: a cold one-shot after the daemon crash should succeed without "
"default startup chatter:\n%s" % excerpt(out_text(cold)))
return False
restart = run_cli(binary, cache, ["daemon", "start"], timeout=60)
second_pid = pid_from(out_text(restart))
+3 -1
View File
@@ -124,7 +124,9 @@ def main():
return 2
# Control: prove the symbol is indexed and queryable.
lp = run_cli(binary, cache, ["cli", "list_projects", "{}"])
# The CLI answers in the lean tree form by default; this guard parses the
# JSON projection, so it asks for it.
lp = run_cli(binary, cache, ["cli", "list_projects", "{\"format\":\"json\"}"])
projects = json.loads((lp.stdout or b"").decode("utf-8", "replace"))["projects"]
name = projects[0]["name"]
sg = run_cli(binary, cache, ["cli", "search_graph",