mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-06 08:35:21 +08:00
Merge pull request #1597 from DeusData/feat/lean-output-contract
feat: make CLI and MCP output lean by default
This commit is contained in:
@@ -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.
|
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:
|
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.
|
# Force human-readable progress without contaminating stdout.
|
||||||
codebase-memory-mcp cli --progress index_repository --repo-path /path/to/repo
|
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.
|
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 |
|
| 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. |
|
| `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. |
|
| `detect_changes` | Map git diff to affected symbols + blast radius with risk classification. |
|
||||||
| `query_graph` | Execute Cypher-like graph queries (read-only). |
|
| `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_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_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_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_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_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. |
|
| `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. |
|
||||||
|
|||||||
@@ -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_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_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_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_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. |
|
| `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. */
|
/* …but was NOT parsed into a real <script> element, and did not execute. */
|
||||||
expect(container.querySelector("script")).toBeNull();
|
expect(container.querySelector("script")).toBeNull();
|
||||||
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
|
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", () => {
|
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", {
|
const res = await callTool<SnippetResult>("get_code_snippet", {
|
||||||
qualified_name: node.qualified_name,
|
qualified_name: node.qualified_name,
|
||||||
project,
|
project,
|
||||||
|
format: "json",
|
||||||
|
source_mode: "full",
|
||||||
});
|
});
|
||||||
setCode(res.source ?? "(source not available)");
|
setCode(res.source ?? "(source not available)");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response
|
|||||||
if (overridden) return overridden;
|
if (overridden) return overridden;
|
||||||
if (url === "/rpc") {
|
if (url === "/rpc") {
|
||||||
return new Response(JSON.stringify({
|
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" } });
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
}
|
}
|
||||||
if (url.startsWith("/api/ui-config")) {
|
if (url.startsWith("/api/ui-config")) {
|
||||||
@@ -221,8 +221,11 @@ describe("StatsTab index modal", () => {
|
|||||||
root_path: "/repo",
|
root_path: "/repo",
|
||||||
indexed_at: "2026-01-01T00:00:00Z",
|
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({
|
return new Response(JSON.stringify({
|
||||||
result: { content: [{ text: JSON.stringify(result) }] },
|
result: { content: [{ text: JSON.stringify(result) }] },
|
||||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useProjects } from "./useProjects";
|
||||||
|
|
||||||
|
const callToolMock = vi.fn();
|
||||||
|
vi.mock("../api/rpc", () => ({
|
||||||
|
callTool: (...args: unknown[]) => callToolMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useProjects machine-readable pagination", () => {
|
||||||
|
beforeEach(() => callToolMock.mockReset());
|
||||||
|
|
||||||
|
it("requests JSON and merges every project and schema page", async () => {
|
||||||
|
callToolMock.mockImplementation(async (name: string, args: Record<string, unknown>) => {
|
||||||
|
if (name === "list_projects") {
|
||||||
|
if (args.offset === 0) {
|
||||||
|
return {
|
||||||
|
projects: [{ name: "alpha", root_path: "/alpha", indexed_at: "now" }],
|
||||||
|
has_more: true,
|
||||||
|
next_offset: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
projects: [{ name: "beta", root_path: "/beta", indexed_at: "now" }],
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name === "get_graph_schema" && args.project === "alpha") {
|
||||||
|
if (args.offset === 0) {
|
||||||
|
return {
|
||||||
|
node_labels: [{ label: "Function", count: 3 }],
|
||||||
|
edge_types: [],
|
||||||
|
total_nodes: 3,
|
||||||
|
total_edges: 2,
|
||||||
|
has_more: true,
|
||||||
|
next_offset: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node_labels: [],
|
||||||
|
edge_types: [{ type: "CALLS", count: 2 }],
|
||||||
|
total_nodes: 3,
|
||||||
|
total_edges: 2,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node_labels: [{ label: "Class", count: 1 }],
|
||||||
|
edge_types: [],
|
||||||
|
total_nodes: 1,
|
||||||
|
total_edges: 0,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useProjects());
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.projects).toHaveLength(2);
|
||||||
|
expect(result.current.projects[0].schema?.node_labels).toEqual([
|
||||||
|
{ label: "Function", count: 3 },
|
||||||
|
]);
|
||||||
|
expect(result.current.projects[0].schema?.edge_types).toEqual([
|
||||||
|
{ type: "CALLS", count: 2 },
|
||||||
|
]);
|
||||||
|
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
|
||||||
|
format: "json",
|
||||||
|
detail: "stats",
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
|
||||||
|
format: "json",
|
||||||
|
detail: "stats",
|
||||||
|
limit: 500,
|
||||||
|
offset: 1,
|
||||||
|
});
|
||||||
|
expect(callToolMock).toHaveBeenCalledWith("get_graph_schema", {
|
||||||
|
project: "alpha",
|
||||||
|
format: "json",
|
||||||
|
limit: 500,
|
||||||
|
offset: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,70 @@ interface ProjectInfo {
|
|||||||
schema: SchemaInfo | null;
|
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 {
|
interface UseProjectsResult {
|
||||||
projects: ProjectInfo[];
|
projects: ProjectInfo[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -23,16 +87,13 @@ export function useProjects(): UseProjectsResult {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await callTool<{ projects: Project[] }>("list_projects");
|
const list = await fetchAllProjects();
|
||||||
const list = result.projects ?? [];
|
|
||||||
|
|
||||||
/* Fetch schema for each project */
|
/* Fetch schema for each project */
|
||||||
const infos: ProjectInfo[] = await Promise.all(
|
const infos: ProjectInfo[] = await Promise.all(
|
||||||
list.map(async (p) => {
|
list.map(async (p) => {
|
||||||
try {
|
try {
|
||||||
const schema = await callTool<SchemaInfo>("get_graph_schema", {
|
const schema = await fetchFullSchema(p.name);
|
||||||
project: p.name,
|
|
||||||
});
|
|
||||||
return { project: p, schema };
|
return { project: p, schema };
|
||||||
} catch {
|
} catch {
|
||||||
return { project: p, schema: null };
|
return { project: p, schema: null };
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ DUP_CHECKED=0
|
|||||||
for TOOL_ARGS in "search_graph --project $PROJECT --name-pattern compute" \
|
for TOOL_ARGS in "search_graph --project $PROJECT --name-pattern compute" \
|
||||||
"search_code --project $PROJECT --query compute" \
|
"search_code --project $PROJECT --query compute" \
|
||||||
"get_architecture --project $PROJECT" \
|
"get_architecture --project $PROJECT" \
|
||||||
"index_status --project $PROJECT"; do
|
"index_status --project $PROJECT --format json"; do
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
ENVELOPE=$("$BINARY" cli $TOOL_ARGS --json 2>/dev/null || true)
|
ENVELOPE=$("$BINARY" cli $TOOL_ARGS --json 2>/dev/null || true)
|
||||||
[ -z "$ENVELOPE" ] && continue
|
[ -z "$ENVELOPE" ] && continue
|
||||||
@@ -575,7 +575,7 @@ fi
|
|||||||
echo "OK: trace_path found $CALLERS caller(s) for 'compute'"
|
echo "OK: trace_path found $CALLERS caller(s) for 'compute'"
|
||||||
|
|
||||||
# 3c: get_graph_schema — verify labels exist
|
# 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
|
echo "FAIL: get_graph_schema (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
|
||||||
fi
|
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")
|
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
|
# $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
|
# argv token, so string-literal args (e.g. replace(f.name,"a","A")) and Cypher
|
||||||
# metacharacters {}|=~<>" need no JSON escaping.
|
# metacharacters {}|=~<>" need no JSON escaping.
|
||||||
cli query_graph --project "$PROJECT" --query "$1" |
|
cli query_graph --project "$PROJECT" --query "$1" --format json |
|
||||||
sed -n '/^rows: /{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
|
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"]
|
# labels(n) → JSON list like ["Function"]
|
||||||
@@ -840,7 +842,7 @@ fi
|
|||||||
|
|
||||||
# B4: STDIN + --json is the generated-client transport. It must return the
|
# B4: STDIN + --json is the generated-client transport. It must return the
|
||||||
# complete MCP result envelope and must NOT emit a deprecation warning.
|
# 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
|
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
|
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
|
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.
|
# B5: --args-file — JSON read from a file resolves; must NOT warn deprecated.
|
||||||
IM_ARGS_FILE=$(smoke_mktemp_file)
|
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
|
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
|
echo "FAIL B5: get_graph_schema --args-file exited non-zero"; cat "$CLI_STDERR"; rm -f "$IM_ARGS_FILE"; exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+43
-1
@@ -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;
|
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) {
|
static const char *cli_activation_action_text(cbm_daemon_runtime_activation_action_t action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL:
|
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;
|
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) {
|
int cbm_cli_print_tool_help(const char *tool_name) {
|
||||||
const char *schema_str = cbm_mcp_tool_input_schema(tool_name);
|
const char *schema_str = cbm_mcp_tool_input_schema(tool_name);
|
||||||
if (!schema_str) {
|
if (!schema_str) {
|
||||||
@@ -13265,6 +13281,8 @@ int cbm_cli_print_tool_help(const char *tool_name) {
|
|||||||
}
|
}
|
||||||
const char *type = "string";
|
const char *type = "string";
|
||||||
const char *desc = "";
|
const char *desc = "";
|
||||||
|
yyjson_val *choices = NULL;
|
||||||
|
yyjson_val *default_value = NULL;
|
||||||
if (yyjson_is_obj(pval)) {
|
if (yyjson_is_obj(pval)) {
|
||||||
yyjson_val *t = yyjson_obj_get(pval, "type");
|
yyjson_val *t = yyjson_obj_get(pval, "type");
|
||||||
if (t && yyjson_is_str(t)) {
|
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)) {
|
if (d && yyjson_is_str(d)) {
|
||||||
desc = yyjson_get_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];
|
char flag[CLI_BUF_256];
|
||||||
snprintf(flag, sizeof(flag), "%s", name);
|
snprintf(flag, sizeof(flag), "%s", name);
|
||||||
cli_snake_to_kebab(flag);
|
cli_snake_to_kebab(flag);
|
||||||
bool req = cli_schema_required_has(required, name);
|
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]) {
|
if (desc[0]) {
|
||||||
printf(" %s", desc);
|
printf(" %s", desc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* private runtime parent. NULL restores the platform default. This is not a
|
||||||
* command-line or environment override. */
|
* command-line or environment override. */
|
||||||
void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent);
|
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) ─────────────────────────── */
|
/* ── Subcommands (wired from main.c) ─────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -213,14 +213,14 @@ char *cbm_client_adapter_pi(const char *binary_path) {
|
|||||||
if (!name || !name[0]) {
|
if (!name || !name[0]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const char *title = cbm_mcp_tool_title(name);
|
|
||||||
const char *description = cbm_mcp_tool_description(name);
|
const char *description = cbm_mcp_tool_description(name);
|
||||||
const char *schema = cbm_mcp_tool_input_schema(name);
|
const char *schema = cbm_mcp_tool_input_schema(name);
|
||||||
sb_append(&sb, " pi.registerTool({\n");
|
sb_append(&sb, " pi.registerTool({\n");
|
||||||
sb_append(&sb, " name: ");
|
sb_append(&sb, " name: ");
|
||||||
sb_append_js_string(&sb, name);
|
sb_append_js_string(&sb, name);
|
||||||
sb_append(&sb, ",\n label: ");
|
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(&sb, ",\n description: ");
|
||||||
sb_append_js_string(&sb, description ? description : "");
|
sb_append_js_string(&sb, description ? description : "");
|
||||||
sb_append(&sb, ",\n parameters: ");
|
sb_append(&sb, ",\n parameters: ");
|
||||||
|
|||||||
+132
-38
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
#include <ctype.h>
|
#include <ctype.h>
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
#define HA_MIN_TOKEN 4 /* skip short/noisy patterns before any work */
|
#define HA_MIN_TOKEN 4 /* skip short/noisy patterns before any work */
|
||||||
#define HA_MAX_TOKEN 96
|
#define HA_MAX_TOKEN 96
|
||||||
#define HA_RESULT_LIMIT 5
|
#define HA_RESULT_LIMIT 5
|
||||||
|
#define HA_LIST_PAGE_LIMIT 500
|
||||||
#define HA_METADATA_CAP 192
|
#define HA_METADATA_CAP 192
|
||||||
#define HA_MAX_WALKUP 8 /* cwd may be a subdir of the indexed root */
|
#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 */
|
#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) {
|
strcmp(status, "invalid_path") != 0) {
|
||||||
const char *kind = NULL;
|
const char *kind = NULL;
|
||||||
const char *detail = NULL;
|
const char *detail = NULL;
|
||||||
|
char *range_detail = NULL;
|
||||||
yyjson_val *coverage = yyjson_obj_get(item, "coverage");
|
yyjson_val *coverage = yyjson_obj_get(item, "coverage");
|
||||||
yyjson_val *row = coverage && yyjson_is_arr(coverage) ? yyjson_arr_get(coverage, 0) : NULL;
|
yyjson_val *row = coverage && yyjson_is_arr(coverage) ? yyjson_arr_get(coverage, 0) : NULL;
|
||||||
if (row) {
|
if (row) {
|
||||||
kind = ha_obj_str(row, "kind");
|
kind = ha_obj_str(row, "kind");
|
||||||
detail = ha_obj_str(row, "detail");
|
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);
|
text = malloc(1536);
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -542,6 +578,7 @@ static char *ha_coverage_context(const char *envelope, const char *rel, bool *is
|
|||||||
status, freshness ? freshness : "unavailable");
|
status, freshness ? freshness : "unavailable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
free(range_detail);
|
||||||
}
|
}
|
||||||
yyjson_doc_free(idoc);
|
yyjson_doc_free(idoc);
|
||||||
yyjson_doc_free(edoc);
|
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_obj_add_str(adoc, aroot, "project", project);
|
||||||
yyjson_mut_arr_add_strcpy(adoc, paths, rel);
|
yyjson_mut_arr_add_strcpy(adoc, paths, rel);
|
||||||
yyjson_mut_obj_add_val(adoc, aroot, "paths", paths);
|
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);
|
char *args = yyjson_mut_write(adoc, 0, NULL);
|
||||||
yyjson_mut_doc_free(adoc);
|
yyjson_mut_doc_free(adoc);
|
||||||
free(project);
|
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))) {
|
if (!ha_canonical_path(cwd, canonical_cwd, sizeof(canonical_cwd))) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
char *envelope = cbm_mcp_handle_tool(srv, "list_projects", "{\"metadata_only\":true}");
|
char *best_name = NULL;
|
||||||
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_root[4096] = {0};
|
char best_root[4096] = {0};
|
||||||
size_t best_length = 0U;
|
size_t best_length = 0U;
|
||||||
size_t index;
|
int64_t offset = 0;
|
||||||
size_t maximum;
|
bool complete = false;
|
||||||
yyjson_val *project;
|
|
||||||
yyjson_arr_foreach(projects, index, maximum, project) {
|
for (;;) {
|
||||||
const char *name = ha_obj_str(project, "name");
|
char args[160];
|
||||||
const char *root = ha_obj_str(project, "root_path");
|
int written = snprintf(args, sizeof(args),
|
||||||
char canonical_root[4096];
|
"{\"metadata_only\":true,\"format\":\"json\",\"limit\":%d,"
|
||||||
if (!name || !name[0] || !root || !root[0] ||
|
"\"offset\":%lld}",
|
||||||
!ha_canonical_path(root, canonical_root, sizeof(canonical_root)) ||
|
HA_LIST_PAGE_LIMIT, (long long)offset);
|
||||||
!ha_path_contains(canonical_root, canonical_cwd)) {
|
if (written < 0 || (size_t)written >= sizeof(args)) {
|
||||||
continue;
|
break;
|
||||||
}
|
}
|
||||||
size_t length = strlen(canonical_root);
|
char *envelope = cbm_mcp_handle_tool(srv, "list_projects", args);
|
||||||
if (length > best_length) {
|
yyjson_doc *edoc = envelope ? yyjson_read(envelope, strlen(envelope), 0) : NULL;
|
||||||
best_name = name;
|
free(envelope);
|
||||||
best_length = length;
|
if (!edoc) {
|
||||||
snprintf(best_root, sizeof(best_root), "%s", canonical_root);
|
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);
|
int written = snprintf(root_out, root_out_size, "%s", best_root);
|
||||||
if (written < 0 || (size_t)written >= root_out_size) {
|
if (written < 0 || (size_t)written >= root_out_size) {
|
||||||
free(result);
|
free(best_name);
|
||||||
result = NULL;
|
best_name = NULL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yyjson_doc_free(doc);
|
return best_name;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Return the nearest indexed graph project for cwd. Probe derived names first
|
/* 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) {
|
if (doc && root) {
|
||||||
yyjson_mut_doc_set_root(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, "project", project);
|
||||||
|
yyjson_mut_obj_add_str(doc, root, "format", "json");
|
||||||
char *args = yyjson_mut_write(doc, 0, NULL);
|
char *args = yyjson_mut_write(doc, 0, NULL);
|
||||||
yyjson_mut_doc_free(doc);
|
yyjson_mut_doc_free(doc);
|
||||||
if (args) {
|
if (args) {
|
||||||
|
|||||||
+118
-2
@@ -31,6 +31,9 @@ static FILE *s_out;
|
|||||||
static atomic_int s_needs_newline;
|
static atomic_int s_needs_newline;
|
||||||
static int s_gbuf_nodes = NOT_SET;
|
static int s_gbuf_nodes = NOT_SET;
|
||||||
static int s_gbuf_edges = 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 cbm_mutex_t s_sink_mutex;
|
||||||
static atomic_int s_sink_mutex_state = ATOMIC_VAR_INIT(LOCK_UNINITIALIZED);
|
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) {}
|
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) {
|
bool cbm_cli_output_flags_parse(int *argc, char **argv, cbm_cli_output_flags_t *flags, char *error,
|
||||||
return explicitly_requested || stderr_is_tty;
|
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]) {
|
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);
|
atomic_store(&s_needs_newline, 0);
|
||||||
s_gbuf_nodes = NOT_SET;
|
s_gbuf_nodes = NOT_SET;
|
||||||
s_gbuf_edges = 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_log_set_sink(cbm_progress_sink_fn);
|
||||||
cbm_mutex_unlock(&s_sink_mutex);
|
cbm_mutex_unlock(&s_sink_mutex);
|
||||||
}
|
}
|
||||||
@@ -197,6 +269,11 @@ void cbm_progress_sink_fini(void) {
|
|||||||
(void)fflush(s_out);
|
(void)fflush(s_out);
|
||||||
}
|
}
|
||||||
s_out = NULL;
|
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);
|
cbm_mutex_unlock(&s_sink_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +415,20 @@ static const event_handler_t handlers[] = {
|
|||||||
|
|
||||||
enum { HANDLER_COUNT = sizeof(handlers) / sizeof(handlers[0]) };
|
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) {
|
void cbm_progress_sink_fn(const char *line) {
|
||||||
progress_sink_mutex_ensure();
|
progress_sink_mutex_ensure();
|
||||||
cbm_mutex_lock(&s_sink_mutex);
|
cbm_mutex_lock(&s_sink_mutex);
|
||||||
@@ -345,6 +436,28 @@ void cbm_progress_sink_fn(const char *line) {
|
|||||||
cbm_mutex_unlock(&s_sink_mutex);
|
cbm_mutex_unlock(&s_sink_mutex);
|
||||||
return;
|
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};
|
char msg[CBM_SZ_64] = {0};
|
||||||
if (!extract_kv(line, "msg", msg, (int)sizeof(msg))) {
|
if (!extract_kv(line, "msg", msg, (int)sizeof(msg))) {
|
||||||
cbm_mutex_unlock(&s_sink_mutex);
|
cbm_mutex_unlock(&s_sink_mutex);
|
||||||
@@ -357,5 +470,8 @@ void cbm_progress_sink_fn(const char *line) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (s_preserve_details) {
|
||||||
|
passthrough_line(line);
|
||||||
|
}
|
||||||
cbm_mutex_unlock(&s_sink_mutex);
|
cbm_mutex_unlock(&s_sink_mutex);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Installs a log sink that maps structured pipeline events to phase labels.
|
* Installs a log sink that maps structured pipeline events to phase labels.
|
||||||
* Interactive terminals enable it automatically; --progress forces it when
|
* Interactive terminals enable it automatically; --progress forces it when
|
||||||
* stderr is redirected.
|
* stderr is redirected, and --quiet disables it.
|
||||||
* Usage:
|
* Usage:
|
||||||
* cbm_progress_sink_init(stderr);
|
* cbm_progress_sink_init(stderr);
|
||||||
* // ... run pipeline ...
|
* // ... run pipeline ...
|
||||||
@@ -13,12 +13,27 @@
|
|||||||
#define CBM_PROGRESS_SINK_H
|
#define CBM_PROGRESS_SINK_H
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.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
|
/* Interactive terminals get lifecycle feedback automatically. --progress
|
||||||
* forces the same behavior for redirected stderr without touching stdout. */
|
* forces the same behavior for redirected stderr; --quiet disables both. */
|
||||||
bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty);
|
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_start(FILE *out, const char *tool_name);
|
||||||
void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, uint64_t elapsed_ms);
|
void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, uint64_t elapsed_ms);
|
||||||
|
|
||||||
|
|||||||
+78
-8
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
#include "cypher/cypher.h"
|
#include "cypher/cypher.h"
|
||||||
#include "foundation/compat.h"
|
#include "foundation/compat.h"
|
||||||
|
#include "foundation/constants.h"
|
||||||
#include "store/store.h"
|
#include "store/store.h"
|
||||||
#include "foundation/platform.h"
|
#include "foundation/platform.h"
|
||||||
#include "foundation/limits.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 uint64_t g_cypher_deadline_ms = 0; /* absolute; 0 = disarmed */
|
||||||
static _Thread_local bool g_cypher_timed_out = false;
|
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 _Thread_local int64_t g_cypher_deadline_override_ms = -1; /* test hook; <0 = default */
|
||||||
|
|
||||||
static void cypher_deadline_arm(void) {
|
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. */
|
* clamp — never a silent truncation. */
|
||||||
int depth_cap = cbm_cypher_max_depth();
|
int depth_cap = cbm_cypher_max_depth();
|
||||||
int max_depth = rel->max_hops > 0 ? rel->max_hops : depth_cap;
|
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) {
|
if (max_depth > depth_cap) {
|
||||||
char req_buf[16];
|
char req_buf[16];
|
||||||
char cap_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};
|
cbm_traverse_result_t tr = {0};
|
||||||
const char *dir = rel->direction ? rel->direction : "outbound";
|
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,
|
int traversal_depth = probe_beyond_depth_cap ? depth_cap + SKIP_ONE : max_depth;
|
||||||
&tr);
|
cbm_store_bfs_trail(store, src->id, dir, rel->types, rel->type_count, traversal_depth,
|
||||||
|
CBM_PERCENT, &tr);
|
||||||
if (tr.truncated) {
|
if (tr.truncated) {
|
||||||
g_cypher_trail_truncated = 1;
|
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. */
|
* fabricated OPTIONAL "no match" row. */
|
||||||
for (int v = 0; v < tr.visited_count; v++) {
|
for (int v = 0; v < tr.visited_count; v++) {
|
||||||
cbm_node_hop_t *hop = &tr.visited[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) {
|
if (hop->hop < rel->min_hops) {
|
||||||
continue;
|
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 */
|
/* Skip */
|
||||||
if (skip_n > 0 && skip_n < rb->row_count) {
|
if (skip_n > 0 && skip_n < rb->row_count) {
|
||||||
for (int i = 0; i < skip_n; i++) {
|
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 */
|
/* Limit */
|
||||||
if (limit >= 0 && rb->row_count > 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 i = limit; i < rb->row_count; i++) {
|
||||||
for (int c = 0; c < rb->col_count; c++) {
|
for (int c = 0; c < rb->col_count; c++) {
|
||||||
safe_str_free(&rb->rows[i][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)) {
|
if (is_scalar_value_func(item->func)) {
|
||||||
return apply_string_func(item->func, raw, func_buf, buf_sz);
|
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
|
/* 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
|
* rotating scratch buffer, which the next column's projection would overwrite
|
||||||
* before rb_add_row copies the assembled row — aliasing every such column to
|
* 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];
|
const char *vars[CBM_SZ_32];
|
||||||
int vc = collect_pattern_vars(q, vars, CBM_SZ_32);
|
int vc = collect_pattern_vars(q, vars, CBM_SZ_32);
|
||||||
build_star_columns(rb, vars, vc);
|
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];
|
const char *vals[CBM_SZ_128];
|
||||||
project_star_row(&bindings[bi], vars, vc, vals);
|
project_star_row(&bindings[bi], vars, vc, vals);
|
||||||
rb_add_row(rb, 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 */
|
/* 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,
|
static void execute_return_simple(cbm_return_clause_t *ret, binding_t *bindings, int bind_count,
|
||||||
int max_rows, result_builder_t *rb) {
|
int max_rows, result_builder_t *rb) {
|
||||||
int proj_cap = max_rows;
|
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;
|
proj_cap = ret->limit;
|
||||||
|
cap_is_engine_budget = false;
|
||||||
}
|
}
|
||||||
for (int bi = 0; bi < bind_count && rb->row_count < proj_cap; bi++) {
|
for (int bi = 0; bi < bind_count && rb->row_count < proj_cap; bi++) {
|
||||||
const char *vals[CBM_SZ_32];
|
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);
|
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 */
|
/* 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) {
|
int max_rows, result_builder_t *rb) {
|
||||||
const char *vars[CYP_MAX_VARS];
|
const char *vars[CYP_MAX_VARS];
|
||||||
int vc = 0;
|
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) {
|
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);
|
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);
|
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
|
/* 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_distinct(rb);
|
||||||
}
|
}
|
||||||
rb_apply_order_by(rb, ret);
|
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,
|
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));
|
memset(out, 0, sizeof(*out));
|
||||||
g_cypher_depth_clamped = 0;
|
g_cypher_depth_clamped = 0;
|
||||||
g_cypher_trail_truncated = 0;
|
g_cypher_trail_truncated = 0;
|
||||||
|
g_cypher_truncated = false;
|
||||||
cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */
|
cypher_deadline_arm(); /* #601: start the wall-clock budget for this query */
|
||||||
if (max_rows <= 0) {
|
if (max_rows <= 0) {
|
||||||
max_rows = CYPHER_RESULT_CEILING;
|
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) {
|
if (execute_single(store, q, project, max_rows, &rb) < 0) {
|
||||||
rb_free(&rb);
|
rb_free(&rb);
|
||||||
cbm_query_free(q);
|
cbm_query_free(q);
|
||||||
|
out->truncated = g_cypher_truncated;
|
||||||
out->error = heap_strdup("query aborted: out of memory or an allocation limit was reached");
|
out->error = heap_strdup("query aborted: out of memory or an allocation limit was reached");
|
||||||
return CBM_NOT_FOUND;
|
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(&rb);
|
||||||
rb_free(&rb2);
|
rb_free(&rb2);
|
||||||
cbm_query_free(q);
|
cbm_query_free(q);
|
||||||
|
out->truncated = g_cypher_truncated;
|
||||||
out->error =
|
out->error =
|
||||||
heap_strdup("query aborted: out of memory or an allocation limit was reached");
|
heap_strdup("query aborted: out of memory or an allocation limit was reached");
|
||||||
return CBM_NOT_FOUND;
|
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) {
|
if (g_cypher_timed_out) {
|
||||||
rb_free(&rb);
|
rb_free(&rb);
|
||||||
cbm_query_free(q);
|
cbm_query_free(q);
|
||||||
|
out->truncated = g_cypher_truncated;
|
||||||
out->error =
|
out->error =
|
||||||
heap_strdup("query exceeded the execution time limit — narrow the pattern with a WHERE "
|
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 "
|
"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 */
|
/* Check ceiling */
|
||||||
if (rb.row_count >= CYPHER_RESULT_CEILING) {
|
if (rb.row_count >= CYPHER_RESULT_CEILING) {
|
||||||
|
g_cypher_truncated = true;
|
||||||
rb_free(&rb);
|
rb_free(&rb);
|
||||||
cbm_query_free(q);
|
cbm_query_free(q);
|
||||||
|
out->truncated = true;
|
||||||
out->error = heap_strdup("result exceeded 100k rows — use narrower filters or add LIMIT");
|
out->error = heap_strdup("result exceeded 100k rows — use narrower filters or add LIMIT");
|
||||||
return CBM_NOT_FOUND;
|
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->col_count = rb.col_count;
|
||||||
out->rows = rb.rows;
|
out->rows = rb.rows;
|
||||||
out->row_count = rb.row_count;
|
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) {
|
if (g_cypher_depth_clamped > 0 || g_cypher_trail_truncated) {
|
||||||
char wbuf[CBM_SZ_256];
|
char wbuf[CBM_SZ_256];
|
||||||
if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) {
|
if (g_cypher_depth_clamped > 0 && g_cypher_trail_truncated) {
|
||||||
|
|||||||
@@ -321,6 +321,10 @@ typedef struct {
|
|||||||
/* rows[row_idx][col_idx] = string value */
|
/* rows[row_idx][col_idx] = string value */
|
||||||
const char ***rows;
|
const char ***rows;
|
||||||
int row_count;
|
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) */
|
/* Non-NULL when the query was rejected (e.g. result too large) */
|
||||||
char *error;
|
char *error;
|
||||||
/* Non-NULL advisory (caller-visible, not an error): e.g. a variable-
|
/* Non-NULL advisory (caller-visible, not an error): e.g. a variable-
|
||||||
|
|||||||
@@ -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) {
|
int cbm_path_info_utf8(const char *path, cbm_path_info_t *out) {
|
||||||
if (!path || !out) {
|
if (!path || !out) {
|
||||||
return CBM_NOT_FOUND;
|
return CBM_PATH_INFO_UNAVAILABLE;
|
||||||
}
|
}
|
||||||
wchar_t *wpath = cbm_path_to_wide(path);
|
wchar_t *wpath = cbm_path_to_wide(path);
|
||||||
if (!wpath) {
|
if (!wpath) {
|
||||||
return CBM_NOT_FOUND;
|
return CBM_PATH_INFO_UNAVAILABLE;
|
||||||
}
|
}
|
||||||
WIN32_FILE_ATTRIBUTE_DATA data;
|
WIN32_FILE_ATTRIBUTE_DATA data;
|
||||||
BOOL ok = GetFileAttributesExW(wpath, GetFileExInfoStandard, &data);
|
BOOL ok = GetFileAttributesExW(wpath, GetFileExInfoStandard, &data);
|
||||||
|
DWORD path_error = ok ? ERROR_SUCCESS : GetLastError();
|
||||||
free(wpath);
|
free(wpath);
|
||||||
if (!ok) {
|
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));
|
memset(out, 0, sizeof(*out));
|
||||||
out->is_directory = (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
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
|
written >= windows_to_unix_ticks
|
||||||
? (int64_t)((written - windows_to_unix_ticks) * NANOSECONDS_PER_WINDOWS_TICK)
|
? (int64_t)((written - windows_to_unix_ticks) * NANOSECONDS_PER_WINDOWS_TICK)
|
||||||
: 0;
|
: 0;
|
||||||
return 0;
|
return CBM_PATH_INFO_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
void cbm_closedir(cbm_dir_t *d) {
|
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) {
|
int cbm_path_info_utf8(const char *path, cbm_path_info_t *out) {
|
||||||
if (!path || !out) {
|
if (!path || !out) {
|
||||||
return CBM_NOT_FOUND;
|
return CBM_PATH_INFO_UNAVAILABLE;
|
||||||
}
|
}
|
||||||
struct stat state;
|
struct stat state;
|
||||||
if (lstat(path, &state) != 0) {
|
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));
|
memset(out, 0, sizeof(*out));
|
||||||
out->is_regular = S_ISREG(state.st_mode);
|
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 =
|
out->mtime_ns =
|
||||||
((int64_t)state.st_mtim.tv_sec * INT64_C(1000000000)) + (int64_t)state.st_mtim.tv_nsec;
|
((int64_t)state.st_mtim.tv_sec * INT64_C(1000000000)) + (int64_t)state.st_mtim.tv_nsec;
|
||||||
#endif
|
#endif
|
||||||
return 0;
|
return CBM_PATH_INFO_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
void cbm_closedir(cbm_dir_t *d) {
|
void cbm_closedir(cbm_dir_t *d) {
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ typedef struct {
|
|||||||
int64_t mtime_ns;
|
int64_t mtime_ns;
|
||||||
} cbm_path_info_t;
|
} 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);
|
int cbm_path_info_utf8(const char *path, cbm_path_info_t *out);
|
||||||
|
|
||||||
/* Open a directory for iteration. Returns NULL on error. */
|
/* Open a directory for iteration. Returns NULL on error. */
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ parse_format:;
|
|||||||
* silently change the operator-selected output shape. */
|
* 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) {
|
void cbm_log_set_sink(cbm_log_sink_fn fn) {
|
||||||
cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
|
cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ typedef enum {
|
|||||||
* unchanged. Call once at startup before any threads or log lines. */
|
* unchanged. Call once at startup before any threads or log lines. */
|
||||||
void cbm_log_init_from_env(void);
|
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);
|
void cbm_log_set_level(CBMLogLevel level);
|
||||||
|
|
||||||
/* Get current log level. */
|
/* Get current log level. */
|
||||||
|
|||||||
+89
-31
@@ -6,6 +6,8 @@
|
|||||||
* cli <tool> <json> Run a single tool call and print result
|
* cli <tool> <json> Run a single tool call and print result
|
||||||
* --version Print version and exit
|
* --version Print version and exit
|
||||||
* --help Print usage 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)
|
* --ui=true/false Enable/disable HTTP UI server (persisted)
|
||||||
* --port=N Set HTTP UI port (persisted, default 9749)
|
* --port=N Set HTTP UI port (persisted, default 9749)
|
||||||
* --tool-profile=analysis|scout Expose a restricted agent tool surface
|
* --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 ───────────────────────────────────────────────────── */
|
/* ── 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.
|
/* Extract text content from MCP tool result envelope and print it.
|
||||||
* MCP results: {"content":[{"type":"text","text":"..."}],"isError":...}
|
* MCP results: {"content":[{"type":"text","text":"..."}],"isError":...}
|
||||||
@@ -665,10 +673,21 @@ static bool cli_first_nonspace_is_brace(const char *s) {
|
|||||||
return *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,
|
static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_locks,
|
||||||
main_local_maintenance_context_t *maintenance_context) {
|
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)) {
|
if (argc == 1 && argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) {
|
||||||
(void)fputs(CLI_USAGE, stdout);
|
(void)fputs(CLI_USAGE, stdout);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -678,7 +697,6 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
|
|||||||
return SKIP_ONE;
|
return SKIP_ONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool progress_requested = cli_strip_flag(&argc, argv, "--progress");
|
|
||||||
bool raw_json = cli_strip_flag(&argc, argv, "--json");
|
bool raw_json = cli_strip_flag(&argc, argv, "--json");
|
||||||
|
|
||||||
/* Supervisor worker role: when this process was spawned as a supervised index
|
/* 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])) {
|
} 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
|
/* raw-JSON back-compat: cli <tool> '{"k":"v"}' (deprecated path). Warn on
|
||||||
* STDERR only — stdout must stay clean JSON for piping. */
|
* STDERR only — stdout must stay clean JSON for piping. */
|
||||||
(void)fprintf(stderr,
|
if (!output_flags.quiet_requested) {
|
||||||
"warning: passing raw JSON to 'cli %s' is deprecated and "
|
(void)fprintf(stderr,
|
||||||
"will be removed in a future release; use flags (run 'cli "
|
"warning: passing raw JSON to 'cli %s' is deprecated and "
|
||||||
"%s --help'), --args-file <path>, or piped stdin.\n",
|
"will be removed in a future release; use flags (run 'cli "
|
||||||
tool_name, tool_name);
|
"%s --help'), --args-file <path>, or piped stdin.\n",
|
||||||
|
tool_name, tool_name);
|
||||||
|
}
|
||||||
args_json = rem_argv[0];
|
args_json = rem_argv[0];
|
||||||
} else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) {
|
} else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) {
|
||||||
/* flag form: cli <tool> --flag value --bare-bool ... */
|
/* 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 =
|
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();
|
uint64_t progress_started_ms = cbm_now_ms();
|
||||||
if (progress) {
|
if (progress) {
|
||||||
cbm_progress_sink_init(stderr);
|
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_binding_failed = false;
|
||||||
bool maintenance_cancelled = false;
|
bool maintenance_cancelled = false;
|
||||||
if (!index_worker) {
|
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 {
|
} else {
|
||||||
srv = cbm_mcp_server_new(NULL);
|
srv = cbm_mcp_server_new(NULL);
|
||||||
if (srv) {
|
if (srv) {
|
||||||
@@ -898,7 +919,8 @@ static void print_help(void) {
|
|||||||
printf("codebase-memory-mcp %s\n\n", CBM_VERSION);
|
printf("codebase-memory-mcp %s\n\n", CBM_VERSION);
|
||||||
printf("Usage:\n");
|
printf("Usage:\n");
|
||||||
printf(" codebase-memory-mcp Run MCP server on stdio\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(" Run one tool locally, then exit\n");
|
||||||
printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] "
|
printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] "
|
||||||
"[--dir=<path>] [--skip-config]\n");
|
"[--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 config <list|get|set|reset>\n");
|
||||||
printf(" codebase-memory-mcp --version Print version\n");
|
printf(" codebase-memory-mcp --version Print version\n");
|
||||||
printf(" codebase-memory-mcp --help Print this help\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("\nUI options:\n");
|
||||||
printf(" --ui=true Enable HTTP graph visualization (persisted)\n");
|
printf(" --ui=true Enable HTTP graph visualization (persisted)\n");
|
||||||
printf(" --ui=false Disable 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);
|
return cbm_daemon_bootstrap_endpoint_new(runtime_parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool main_local_cli_feedback_enabled(int argc, char **argv) {
|
static bool main_cli_flag_present(int argc, char **argv, const char *flag) {
|
||||||
bool requested = false;
|
if (!argv || !flag) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
for (int index = 1; index < argc; index++) {
|
for (int index = 1; index < argc; index++) {
|
||||||
if (argv[index] && strcmp(argv[index], "--progress") == 0) {
|
if (argv[index] && strcmp(argv[index], flag) == 0) {
|
||||||
requested = true;
|
return true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.
|
/* 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-
|
* lifecycle used to, instead of deadlocking behind the pinned daemon. Same-
|
||||||
* or newer-build daemons and dev builds are never auto-drained. */
|
* or newer-build daemons and dev builds are never auto-drained. */
|
||||||
static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade(
|
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);
|
cbm_daemon_bootstrap_status_t status = cbm_daemon_bootstrap_execute(config, result);
|
||||||
if (status != CBM_DAEMON_BOOTSTRAP_CONFLICT) {
|
if (status != CBM_DAEMON_BOOTSTRAP_CONFLICT) {
|
||||||
return status;
|
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)) {
|
!main_semver_newer(config->identity->semantic_version, active.semantic_version)) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
(void)fprintf(stderr,
|
if (report_lifecycle) {
|
||||||
"codebase-memory-mcp: retiring the active permanent daemon (%s, pid %lu) for "
|
(void)fprintf(stderr,
|
||||||
"this newer build (%s)\n",
|
"codebase-memory-mcp: retiring the active permanent daemon (%s, pid %lu) "
|
||||||
active.semantic_version, (unsigned long)active.daemon_pid,
|
"for this newer build (%s)\n",
|
||||||
config->identity->semantic_version);
|
active.semantic_version, (unsigned long)active.daemon_pid,
|
||||||
|
config->identity->semantic_version);
|
||||||
|
}
|
||||||
cbm_daemon_runtime_activation_result_t drain;
|
cbm_daemon_runtime_activation_result_t drain;
|
||||||
if (!cbm_daemon_runtime_request_activation_shutdown(config->endpoint, config->identity,
|
if (!cbm_daemon_runtime_request_activation_shutdown(config->endpoint, config->identity,
|
||||||
CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE,
|
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
|
* 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
|
* one is spawned for this command — with a hint that `daemon start` removes
|
||||||
* that per-command cost. Only supervised index workers stay in-process. */
|
* 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) {
|
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);
|
bool quiet_requested) {
|
||||||
|
cbm_daemon_ipc_endpoint_t *endpoint = main_daemon_endpoint_new();
|
||||||
char executable_path[MAIN_PATH_CAP] = {0};
|
char executable_path[MAIN_PATH_CAP] = {0};
|
||||||
cbm_daemon_build_identity_t identity;
|
cbm_daemon_build_identity_t identity;
|
||||||
bool prepared =
|
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,
|
.startup_timeout_ms = MAIN_MCP_STARTUP_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
cbm_daemon_bootstrap_result_t bootstrap;
|
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);
|
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||||
if (status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap.client) {
|
if (status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap.client) {
|
||||||
(void)fprintf(stderr, "error: %s\n",
|
(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");
|
: "no CBM daemon connection for CLI execution");
|
||||||
return NULL;
|
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. "
|
(void)fprintf(stderr, "hint: this command started a temporary CBM daemon. "
|
||||||
"`codebase-memory-mcp daemon start` keeps one warm and removes this "
|
"`codebase-memory-mcp daemon start` keeps one warm and removes this "
|
||||||
"startup cost from every CLI command.\n");
|
"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_result_t start_result;
|
||||||
cbm_daemon_bootstrap_status_t start_status =
|
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) {
|
if (start_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !start_result.client) {
|
||||||
(void)fprintf(stderr, "error: %s\n",
|
(void)fprintf(stderr, "error: %s\n",
|
||||||
start_result.message[0] ? start_result.message
|
start_result.message[0] ? start_result.message
|
||||||
@@ -2513,7 +2559,19 @@ int main(int argc, char **argv) {
|
|||||||
|
|
||||||
cbm_cli_set_version(CBM_VERSION);
|
cbm_cli_set_version(CBM_VERSION);
|
||||||
cbm_profile_init();
|
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;
|
cbm_mcp_tool_profile_t tool_profile = CBM_MCP_TOOL_PROFILE_ALL;
|
||||||
if (role == CBM_DAEMON_PROCESS_MCP_CLIENT &&
|
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)fputs("Preparing one-shot local CBM command...\n", feedback);
|
||||||
(void)fflush(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];
|
char local_executable[MAIN_PATH_CAP];
|
||||||
cbm_daemon_build_identity_t local_identity;
|
cbm_daemon_build_identity_t local_identity;
|
||||||
cbm_project_lock_manager_t *project_locks =
|
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_result_t bootstrap_result;
|
||||||
cbm_daemon_bootstrap_status_t bootstrap_status =
|
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);
|
cbm_daemon_ipc_endpoint_free(endpoint);
|
||||||
if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) {
|
if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) {
|
||||||
main_report_client_bootstrap_failure(role, &bootstrap_result);
|
main_report_client_bootstrap_failure(role, &bootstrap_result);
|
||||||
|
|||||||
+949
-44
File diff suppressed because it is too large
Load Diff
+35
-6
@@ -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
|
* Tool responses are consumed by LLM agents, where every byte is context
|
||||||
* tokens. TOON encodes the same data as JSON but declares tabular fields
|
* tokens. The tree format declares tabular fields once in a header and
|
||||||
* once in a header and streams rows line by line, cutting 40-60% of tokens
|
* streams rows line by line, removing repeated-key overhead while retaining
|
||||||
* on homogeneous result sets at equal-or-better retrieval accuracy
|
* scalar key-value lines and explicit table lengths.
|
||||||
* (toonformat.dev/guide/benchmarks). Emitters here cover the subset we
|
|
||||||
* emit: scalar key-value lines and flat tables with explicit [N] lengths.
|
|
||||||
*
|
*
|
||||||
* Quoting: a cell/scalar is double-quoted iff it is empty, has leading or
|
* 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
|
* 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);
|
char *cbm_sb_finish(cbm_sb_t *sb);
|
||||||
void cbm_sb_free(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). */
|
/* `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_str(cbm_sb_t *sb, const char *key, const char *val);
|
||||||
void cbm_tree_scalar_int(cbm_sb_t *sb, const char *key, long long v);
|
void cbm_tree_scalar_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_cell_bool(cbm_sb_t *sb, bool v, bool first);
|
||||||
void cbm_tree_row_end(cbm_sb_t *sb);
|
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 */
|
#endif /* CBM_MCP_COMPACT_OUT_H */
|
||||||
|
|||||||
+6236
-1882
File diff suppressed because it is too large
Load Diff
@@ -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
|
/* Tool metadata by name (static; do not free; NULL when unknown). Used by the
|
||||||
* generated client adapters so they can emit descriptions and parameter
|
* generated client adapters so they can emit descriptions and parameter
|
||||||
* schemas without drifting from tools/list. */
|
* 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);
|
const char *cbm_mcp_tool_description(const char *tool_name);
|
||||||
|
|
||||||
/* Render the top-level --help "Tools:" block from the registry so the help
|
/* Render the top-level --help "Tools:" block from the registry so the help
|
||||||
|
|||||||
@@ -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,
|
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 *file_pattern, const char *tmpfile,
|
||||||
const char *filelist, const char *root_path);
|
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
|
#endif
|
||||||
|
|||||||
@@ -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_log_info("publish.timing", "block", "fts", "elapsed_ms", itoa_buf((int)elapsed_ms(t_pub)));
|
||||||
cbm_clock_gettime(CLOCK_MONOTONIC, &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)) {
|
if (ok && !cbm_store_check_integrity(store)) {
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+382
-101
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
// for ISO timestamp
|
// for ISO timestamp
|
||||||
|
|
||||||
|
#include <limits.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include "foundation/constants.h"
|
#include "foundation/constants.h"
|
||||||
#include "foundation/hash_table.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 ───────────────────────────────────────────────── */
|
/* ── 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) {
|
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 =
|
sqlite3_stmt *stmt =
|
||||||
prepare_cached(s, &s->stmt_upsert_project,
|
prepare_cached(s, &s->stmt_upsert_project,
|
||||||
"INSERT INTO projects (name, indexed_at, root_path) VALUES (?1, ?2, ?3) "
|
"INSERT INTO projects (name, indexed_at, root_path) VALUES (?1, ?2, ?3) "
|
||||||
"ON CONFLICT(name) DO UPDATE SET indexed_at=?2, root_path=?3;");
|
"ON CONFLICT(name) DO UPDATE SET indexed_at=?2, root_path=?3;");
|
||||||
if (!stmt) {
|
if (!stmt) {
|
||||||
return CBM_STORE_ERR;
|
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||||
}
|
}
|
||||||
|
|
||||||
char ts[CBM_SZ_64];
|
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);
|
int rc = sqlite3_step(stmt);
|
||||||
if (rc != SQLITE_DONE) {
|
if (rc != SQLITE_DONE) {
|
||||||
store_set_error_sqlite(s, "upsert_project");
|
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
|
if (cbm_store_generation_advance(s) != CBM_STORE_OK) {
|
||||||
* identity minted once per DB FILE (a full reindex publishes a fresh file
|
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||||
* 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
|
if (exec_sql(s, release) != CBM_STORE_OK) {
|
||||||
* file can never validate against the rebuilt one, whose node ids all
|
return rollback_savepoint_preserving_error(s, rollback, release);
|
||||||
* differ). mutation_gen increments on every project upsert — the choke
|
}
|
||||||
* point every index run (full, incremental, watcher) passes through.
|
|
||||||
* Created here rather than in the byte-level writer: adding a table to
|
|
||||||
* its hand-built sqlite_master is rootpage surgery for zero benefit. */
|
|
||||||
(void)sqlite3_exec(s->db,
|
|
||||||
"CREATE TABLE IF NOT EXISTS store_meta (k TEXT PRIMARY KEY, v TEXT);"
|
|
||||||
"INSERT OR IGNORE INTO store_meta VALUES"
|
|
||||||
"('db_uid', lower(hex(randomblob(8))));"
|
|
||||||
"INSERT OR IGNORE INTO store_meta VALUES('mutation_gen','0');"
|
|
||||||
"UPDATE store_meta SET v = CAST(CAST(v AS INTEGER)+1 AS TEXT) "
|
|
||||||
"WHERE k='mutation_gen';",
|
|
||||||
NULL, NULL, NULL);
|
|
||||||
return CBM_STORE_OK;
|
return CBM_STORE_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Opaque store generation for pagination cursors: "u<db_uid>g<mutation_gen>",
|
/* 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
|
* or "legacy" only when the DB genuinely predates store_meta. */
|
||||||
* it). A cursor whose embedded generation mismatches the store's current one
|
|
||||||
* is stale — the graph may have changed under it. */
|
|
||||||
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) {
|
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) {
|
||||||
if (!s || !s->db || !buf || bufsz == 0) {
|
if (!s || !s->db || !buf || bufsz == 0) {
|
||||||
return CBM_STORE_ERR;
|
return CBM_STORE_ERR;
|
||||||
}
|
}
|
||||||
snprintf(buf, bufsz, "legacy");
|
bool absent = false;
|
||||||
sqlite3_stmt *stmt = NULL;
|
char uid[17] = {0};
|
||||||
if (sqlite3_prepare_v2(s->db,
|
char counter[21] = {0};
|
||||||
"SELECT (SELECT v FROM store_meta WHERE k='db_uid'),"
|
uint64_t counter_value = 0;
|
||||||
" (SELECT v FROM store_meta WHERE k='mutation_gen');",
|
if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK) {
|
||||||
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
buf[0] = '\0';
|
||||||
return CBM_STORE_OK; /* pre-migration DB: stable "legacy" generation */
|
return CBM_STORE_ERR;
|
||||||
}
|
}
|
||||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
(void)counter_value;
|
||||||
const char *uid = (const char *)sqlite3_column_text(stmt, 0);
|
int written =
|
||||||
const char *gen = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
|
absent ? snprintf(buf, bufsz, "legacy") : snprintf(buf, bufsz, "u%sg%s", uid, counter);
|
||||||
if (uid && gen) {
|
if (written < 0 || (size_t)written >= bufsz) {
|
||||||
snprintf(buf, bufsz, "u%sg%s", uid, gen);
|
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;
|
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;
|
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 "
|
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;
|
sqlite3_stmt *stmt = NULL;
|
||||||
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||||
return CBM_STORE_ERR;
|
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,
|
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 visited_count, const char *types_clause, const char **edge_types,
|
||||||
int edge_type_count, cbm_edge_info_t **out_edges,
|
int edge_type_count, int max_edges, cbm_edge_info_t **out_edges,
|
||||||
int *out_edge_count) {
|
int *out_edge_count, bool *out_truncated) {
|
||||||
*out_edges = NULL;
|
*out_edges = NULL;
|
||||||
*out_edge_count = 0;
|
*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
|
/* Visited-ID set via a per-connection TEMP table. The previous approach
|
||||||
* interpolated the ids into a fixed 4KB SQL string: past ~1000 visited
|
* 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);"
|
"CREATE TEMP TABLE IF NOT EXISTS bfs_ids (id INTEGER PRIMARY KEY);"
|
||||||
"DELETE FROM bfs_ids;",
|
"DELETE FROM bfs_ids;",
|
||||||
NULL, NULL, NULL) != SQLITE_OK) {
|
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;
|
sqlite3_stmt *ins = NULL;
|
||||||
if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_ids(id) VALUES (?1)", CBM_NOT_FOUND,
|
if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_ids(id) VALUES (?1)", CBM_NOT_FOUND,
|
||||||
&ins, NULL) != SQLITE_OK) {
|
&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);
|
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++) {
|
for (int i = 0; i < visited_count; i++) {
|
||||||
sqlite3_reset(ins);
|
sqlite3_reset(ins);
|
||||||
sqlite3_bind_int64(ins, SKIP_ONE, visited[i].node.id);
|
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);
|
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];
|
char edge_sql[ST_SQL_BUF];
|
||||||
snprintf(edge_sql, sizeof(edge_sql),
|
snprintf(edge_sql, sizeof(edge_sql),
|
||||||
"SELECT n1.name, n2.name, e.type, e.source_id, e.target_id, e.properties "
|
"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 "
|
"JOIN nodes n2 ON n2.id = e.target_id "
|
||||||
"WHERE e.source_id IN (SELECT id FROM bfs_ids) "
|
"WHERE e.source_id IN (SELECT id FROM bfs_ids) "
|
||||||
"AND e.target_id IN (SELECT id FROM bfs_ids) "
|
"AND e.target_id IN (SELECT id FROM bfs_ids) "
|
||||||
"AND e.type IN (%s)",
|
"AND e.type IN (%s) "
|
||||||
types_clause);
|
"ORDER BY e.source_id, e.target_id, e.type%s",
|
||||||
|
types_clause, limit_clause);
|
||||||
|
|
||||||
sqlite3_stmt *estmt = NULL;
|
sqlite3_stmt *estmt = NULL;
|
||||||
int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL);
|
int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL);
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
*out_edges = NULL;
|
store_set_error_sqlite(s, "bfs edges prepare");
|
||||||
*out_edge_count = 0;
|
return CBM_STORE_ERR;
|
||||||
return CBM_STORE_OK;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (edge_type_count > 0) {
|
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);
|
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_edges = edges;
|
||||||
*out_edge_count = en;
|
*out_edge_count = en;
|
||||||
return CBM_STORE_OK;
|
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,
|
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,
|
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));
|
memset(out, 0, sizeof(*out));
|
||||||
|
if (max_results < 0) {
|
||||||
|
max_results = 0;
|
||||||
|
}
|
||||||
|
|
||||||
cbm_node_t root = {0};
|
cbm_node_t root = {0};
|
||||||
int rc = cbm_store_find_node_by_id(s, start_id, &root);
|
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";
|
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;
|
int cte_row_limit = 0;
|
||||||
if (trail) {
|
if (trail) {
|
||||||
cte_row_limit = bfs_cte_row_limit_for_depth(max_results, max_depth);
|
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 "
|
"FROM bfs JOIN nodes n ON n.id = bfs.node_id "
|
||||||
"WHERE bfs.hop > 0 ORDER BY bfs.hop, n.id LIMIT %d;",
|
"WHERE bfs.hop > 0 ORDER BY bfs.hop, n.id LIMIT %d;",
|
||||||
(long long)start_id, next_id, join_cond, types_clause, max_depth,
|
(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 {
|
} else {
|
||||||
snprintf(sql, sizeof(sql),
|
snprintf(sql, sizeof(sql),
|
||||||
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) pairs.
|
/* 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 "
|
"GROUP BY n.id "
|
||||||
"ORDER BY hop, n.id "
|
"ORDER BY hop, n.id "
|
||||||
"LIMIT %d;",
|
"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;
|
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);
|
snprintf(limit_buf, sizeof(limit_buf), "%d", cte_row_limit);
|
||||||
cbm_log_warn("cypher.trail_truncated", "cte_rows", limit_buf, "result", "partial");
|
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 = visited;
|
||||||
out->visited_count = n;
|
out->visited_count = n;
|
||||||
|
|
||||||
/* Collect edges between visited nodes (including root) */
|
/* Edge properties are a secondary, potentially dense all-pairs lookup.
|
||||||
if (n > 0) {
|
* Lean callers pass max_edges=0 and avoid it entirely; diagnostics callers
|
||||||
bfs_collect_edges(s, start_id, out->visited, n, types_clause, edge_types, edge_type_count,
|
* pass an explicit ceiling and receive an honest saturation bit. */
|
||||||
&out->edges, &out->edge_count);
|
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 {
|
} else {
|
||||||
out->edges = NULL;
|
out->edges = NULL;
|
||||||
out->edge_count = 0;
|
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 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) {
|
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,
|
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,
|
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,
|
const char **edge_types, int edge_type_count, int max_depth,
|
||||||
int max_results, cbm_traverse_result_t *out) {
|
int max_results, cbm_traverse_result_t *out) {
|
||||||
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
|
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
|
/* 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;
|
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 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 keyword_count, int limit, cbm_vector_result_t **out,
|
||||||
int *out_count) {
|
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;
|
return CBM_STORE_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scan all node vectors, compute per-keyword cosine, take min.
|
/* Use the first keyword for a cheap candidate ordering, then score each
|
||||||
* We use the FIRST keyword as the SQL sort (for top-K pre-filter),
|
* materialized candidate by min-cosine across every keyword. */
|
||||||
* then re-score with min across all keywords in the append helper. */
|
|
||||||
const char *sql = "SELECT n.id, n.name, n.qualified_name, n.file_path, n.label,"
|
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"
|
" cbm_cosine_i8(v.vector, ?1) as score, v.vector"
|
||||||
" FROM node_vectors v"
|
" FROM node_vectors v"
|
||||||
" INNER JOIN nodes n ON n.id = v.node_id"
|
" INNER JOIN nodes n ON n.id = v.node_id"
|
||||||
" WHERE v.project = ?2"
|
" WHERE v.project = ?2"
|
||||||
" AND n.label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")"
|
" AND n.label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")"
|
||||||
" ORDER BY score DESC"
|
" ORDER BY score DESC, n.id ASC"
|
||||||
" LIMIT ?3";
|
" LIMIT ?3";
|
||||||
|
|
||||||
sqlite3_stmt *stmt = NULL;
|
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;
|
return CBM_STORE_ERR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */
|
/* A fixed 5x prefilter is normally ample, but it is not a proof: a later
|
||||||
int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5;
|
* 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_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_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 kw_buf[VS_STR_BUF];
|
||||||
char fl_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(kw_buf, sizeof(kw_buf), "%d", actual_kw);
|
||||||
snprintf(fl_buf, sizeof(fl_buf), "%d", fetch_limit);
|
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);
|
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);
|
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 */
|
/* Trim to requested limit */
|
||||||
int final_limit = limit > 0 ? limit : CBM_SZ_16;
|
int final_limit = requested_limit;
|
||||||
if (count > final_limit) {
|
if (count > final_limit) {
|
||||||
for (int i = final_limit; i < count; i++) {
|
for (int i = final_limit; i < count; i++) {
|
||||||
free(results[i].name);
|
free(results[i].name);
|
||||||
|
|||||||
+22
-5
@@ -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). */
|
/* 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);
|
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.
|
/* Get distinct canonical File-node paths, with a non-Folder node fallback for legacy/manual
|
||||||
* Returns CBM_STORE_OK or CBM_STORE_ERR. */
|
* 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);
|
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
|
/* Persisted index-format identity. Bump when a change alters the QN scheme
|
||||||
@@ -238,8 +238,10 @@ typedef struct {
|
|||||||
int visited_count;
|
int visited_count;
|
||||||
cbm_edge_info_t *edges;
|
cbm_edge_info_t *edges;
|
||||||
int edge_count;
|
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 truncated;
|
||||||
|
bool edges_truncated; /* optional edge-data ceiling reached; node counts stay exact */
|
||||||
} cbm_traverse_result_t;
|
} cbm_traverse_result_t;
|
||||||
|
|
||||||
/* ── Schema introspection ───────────────────────────────────────── */
|
/* ── Schema introspection ───────────────────────────────────────── */
|
||||||
@@ -442,9 +444,17 @@ int cbm_store_checkpoint(cbm_store_t *s);
|
|||||||
* connection, in bytes; -1 = unlimited (SQLite default / pre-fix). */
|
* connection, in bytes; -1 = unlimited (SQLite default / pre-fix). */
|
||||||
int64_t cbm_store_journal_size_limit(cbm_store_t *s);
|
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:
|
/* Opaque store generation for pagination-cursor staleness detection:
|
||||||
* "u<db_uid>g<mutation_gen>" — db_uid is minted per DB file, mutation_gen
|
* "u<16-lower-hex-db_uid>g<canonical-uint64-mutation_gen>". Returns "legacy"
|
||||||
* bumps on every index run. "legacy" for DBs predating store_meta. */
|
* 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);
|
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz);
|
||||||
|
|
||||||
/* Seal a fully-written staging database before atomic publication.
|
/* 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,
|
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,
|
const char **edge_types, int edge_type_count, int max_depth,
|
||||||
int max_results, cbm_traverse_result_t *out);
|
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).
|
/* 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
|
* Seeds are EXCLUDED from the result (impact semantics); MIN(hop) across the
|
||||||
|
|||||||
@@ -1775,7 +1775,8 @@ static bool rpc_is_allowed_for_ui(const char *body, size_t body_len) {
|
|||||||
const char *name_text = yyjson_is_str(name) ? yyjson_get_str(name) : NULL;
|
const char *name_text = yyjson_is_str(name) ? yyjson_get_str(name) : NULL;
|
||||||
bool allowed =
|
bool allowed =
|
||||||
method_text && strcmp(method_text, "tools/call") == 0 && name_text &&
|
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);
|
yyjson_doc_free(document);
|
||||||
return allowed;
|
return allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,10 @@ project_name() { printf '%s' "$1" | sed 's#^/##; s#[^A-Za-z0-9._-]#-#g'; }
|
|||||||
# Run query_graph and print the first integer in the JSON response (or -1).
|
# Run query_graph and print the first integer in the JSON response (or -1).
|
||||||
query_count() {
|
query_count() {
|
||||||
local proj="$1" cypher="$2" resp
|
local proj="$1" cypher="$2" resp
|
||||||
resp="$("$BIN" cli query_graph "{\"project\":\"$proj\",\"query\":\"$cypher\"}" 2>/dev/null)"
|
resp="$("$BIN" cli query_graph "{\"project\":\"$proj\",\"query\":\"$cypher\",\"format\":\"json\"}" 2>/dev/null)"
|
||||||
printf '%s' "$resp" | "$PY" -c 'import sys,re
|
printf '%s' "$resp" | "$PY" -c 'import json,sys
|
||||||
s=sys.stdin.read()
|
d=json.load(sys.stdin); rows=d.get("rows", [])
|
||||||
m=re.findall(r"-?\d+", s)
|
print(rows[0][0] if rows and rows[0] else -1)' 2>/dev/null || echo -1
|
||||||
print(m[0] if m else -1)' 2>/dev/null || echo -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_lang() {
|
assert_lang() {
|
||||||
|
|||||||
+416
-55
@@ -21,6 +21,7 @@
|
|||||||
#include <daemon/runtime.h>
|
#include <daemon/runtime.h>
|
||||||
#include <daemon/version_cohort.h>
|
#include <daemon/version_cohort.h>
|
||||||
#include <foundation/constants.h>
|
#include <foundation/constants.h>
|
||||||
|
#include <foundation/log.h>
|
||||||
#include <foundation/platform.h>
|
#include <foundation/platform.h>
|
||||||
#include <mcp/mcp.h>
|
#include <mcp/mcp.h>
|
||||||
#include <pipeline/pipeline.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);
|
char *block, size_t block_size);
|
||||||
bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str);
|
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) {
|
TEST(cli_progress_visibility_policy) {
|
||||||
ASSERT_TRUE(cbm_cli_progress_enabled(true, false));
|
ASSERT_TRUE(cbm_cli_progress_enabled(true, false, false));
|
||||||
ASSERT_TRUE(cbm_cli_progress_enabled(false, true));
|
ASSERT_TRUE(cbm_cli_progress_enabled(false, false, true));
|
||||||
ASSERT_FALSE(cbm_cli_progress_enabled(false, false));
|
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();
|
PASS();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +232,82 @@ TEST(cli_progress_sink_accepts_worker_json_logs) {
|
|||||||
PASS();
|
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 };
|
enum { CLI_PROGRESS_RACE_THREADS = 4, CLI_PROGRESS_RACE_ROUNDS = 32 };
|
||||||
|
|
||||||
typedef struct {
|
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);
|
snprintf(dir_arg, sizeof(dir_arg), "--dir=%s", install_dir);
|
||||||
char *install_argv[] = {"--force", "--skip-config", "--yes", dir_arg};
|
char *install_argv[] = {"--force", "--skip-config", "--yes", dir_arg};
|
||||||
int install_rc = child_ready ? cli_test_cmd_install(4, install_argv) : -1;
|
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);
|
cbm_set_auto_answer_for_test(0);
|
||||||
|
|
||||||
int child_status = 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");
|
FAIL("cbm_mkdtemp failed");
|
||||||
|
|
||||||
const char *const env_names[] = {
|
const char *const env_names[] = {
|
||||||
"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR",
|
"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME", "APPDATA",
|
||||||
"XDG_CONFIG_HOME", "APPDATA",
|
|
||||||
};
|
};
|
||||||
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
|
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
|
||||||
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
|
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\"") &&
|
relocated_plan && strstr(relocated_plan, "\"omp\"") &&
|
||||||
test_json_string_array_contains(relocated_root, "config_files_planned",
|
test_json_string_array_contains(relocated_root, "config_files_planned",
|
||||||
omp_relocated_mcp) &&
|
omp_relocated_mcp) &&
|
||||||
test_json_string_array_contains(relocated_root, "agent_files_planned",
|
test_json_string_array_contains(relocated_root, "agent_files_planned", omp_relocated_agent);
|
||||||
omp_relocated_agent);
|
|
||||||
yyjson_doc_free(relocated_doc);
|
yyjson_doc_free(relocated_doc);
|
||||||
free(relocated_plan);
|
free(relocated_plan);
|
||||||
cbm_unsetenv("PI_CODING_AGENT_DIR");
|
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. */
|
/* None of the plan-only invocations may have written OMP-owned content. */
|
||||||
struct stat state;
|
struct stat state;
|
||||||
bool plan_did_not_mutate =
|
bool plan_did_not_mutate =
|
||||||
(stat(omp_default_mcp, &state) != 0) &&
|
(stat(omp_default_mcp, &state) != 0) && (stat(omp_default_agent, &state) != 0) &&
|
||||||
(stat(omp_default_agent, &state) != 0) &&
|
(stat(omp_profile_mcp, &state) != 0) && (stat(omp_profile_agent, &state) != 0) &&
|
||||||
(stat(omp_profile_mcp, &state) != 0) &&
|
(stat(omp_relocated_mcp, &state) != 0) && (stat(omp_relocated_agent, &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++) {
|
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
|
||||||
restore_test_env(env_names[i], saved_env[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);
|
test_rmdir_r(tmpdir);
|
||||||
if (!default_plan_ok || !profile_plan_ok || !relocated_plan_ok || !fallback_plan_ok ||
|
if (!default_plan_ok || !profile_plan_ok || !relocated_plan_ok || !fallback_plan_ok ||
|
||||||
!plan_did_not_mutate) {
|
!plan_did_not_mutate) {
|
||||||
fprintf(stderr,
|
fprintf(stderr, "omp diag default=%d profile=%d relocated=%d fallback=%d plan_clean=%d\n",
|
||||||
"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,
|
default_plan_ok, profile_plan_ok, relocated_plan_ok, fallback_plan_ok,
|
||||||
plan_did_not_mutate);
|
plan_did_not_mutate);
|
||||||
FAIL("OMP must resolve ~/.omp/agent under the documented fallback, honor OMP_PROFILE "
|
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();
|
PASS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
TEST(cli_registry_omp_named_profile_install_and_uninstall_preserve_user_content) {
|
TEST(cli_registry_omp_named_profile_install_and_uninstall_preserve_user_content) {
|
||||||
char tmpdir[256];
|
char tmpdir[256];
|
||||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-registry-omp-lifecycle-XXXXXX");
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-registry-omp-lifecycle-XXXXXX");
|
||||||
if (!cbm_mkdtemp(tmpdir))
|
if (!cbm_mkdtemp(tmpdir))
|
||||||
FAIL("cbm_mkdtemp failed");
|
FAIL("cbm_mkdtemp failed");
|
||||||
|
|
||||||
const char *const env_names[] = {"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR",
|
const char *const env_names[] = {
|
||||||
"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])];
|
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
|
||||||
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
|
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
|
||||||
saved_env[i] = save_test_env(env_names[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") &&
|
install_rc == 0 && installed_mcp && strstr(installed_mcp, "codebase-memory-mcp") &&
|
||||||
strstr(installed_mcp, binary_path) && installed_instructions &&
|
strstr(installed_mcp, binary_path) && installed_instructions &&
|
||||||
strcmp(installed_instructions, user_instructions) == 0 &&
|
strcmp(installed_instructions, user_instructions) == 0 &&
|
||||||
test_file_contains_all(skill_path,
|
test_file_contains_all(
|
||||||
(const char *const[]){"search_graph", "trace_path",
|
skill_path,
|
||||||
"Sessions and Subagents"},
|
(const char *const[]){"search_graph", "trace_path", "Sessions and Subagents"}, 3U) &&
|
||||||
3U) &&
|
|
||||||
test_file_contains_all(scout_path,
|
test_file_contains_all(scout_path,
|
||||||
(const char *const[]){"autoloadSkills: [codebase-memory]",
|
(const char *const[]){"autoloadSkills: [codebase-memory]",
|
||||||
"mcp__codebase_memory_mcp_search_graph"},
|
"mcp__codebase_memory_mcp_search_graph"},
|
||||||
2U) &&
|
2U) &&
|
||||||
test_file_contains_all(verify_path,
|
test_file_contains_all(
|
||||||
(const char *const[]){"read-summarize: false",
|
verify_path,
|
||||||
"mcp__codebase_memory_mcp_trace_path"},
|
(const char *const[]){"read-summarize: false", "mcp__codebase_memory_mcp_trace_path"},
|
||||||
2U) &&
|
2U) &&
|
||||||
test_file_contains_all(auditor_path,
|
test_file_contains_all(
|
||||||
(const char *const[]){"autoloadSkills: [codebase-memory]",
|
auditor_path,
|
||||||
"mcp__codebase_memory_mcp_check_index_coverage"},
|
(const char *const[]){"autoloadSkills: [codebase-memory]",
|
||||||
2U);
|
"mcp__codebase_memory_mcp_check_index_coverage"},
|
||||||
|
2U);
|
||||||
free(installed_mcp);
|
free(installed_mcp);
|
||||||
free(installed_instructions);
|
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_instructions = read_test_file_alloc(instructions_path);
|
||||||
char *preserved_verify = read_test_file_alloc(verify_path);
|
char *preserved_verify = read_test_file_alloc(verify_path);
|
||||||
char *mcp_after = read_test_file_alloc(mcp_path);
|
char *mcp_after = read_test_file_alloc(mcp_path);
|
||||||
bool uninstalled =
|
bool uninstalled = uninstall_rc == 0 && preserved_instructions &&
|
||||||
uninstall_rc == 0 && preserved_instructions &&
|
strcmp(preserved_instructions, user_instructions) == 0 && preserved_verify &&
|
||||||
strcmp(preserved_instructions, user_instructions) == 0 && preserved_verify &&
|
strcmp(preserved_verify, modified_verify) == 0 &&
|
||||||
strcmp(preserved_verify, modified_verify) == 0 && stat(skill_path, &state) != 0 &&
|
stat(skill_path, &state) != 0 && stat(scout_path, &state) != 0 &&
|
||||||
stat(scout_path, &state) != 0 && stat(auditor_path, &state) != 0 &&
|
stat(auditor_path, &state) != 0 &&
|
||||||
(!mcp_after || !strstr(mcp_after, "codebase-memory-mcp"));
|
(!mcp_after || !strstr(mcp_after, "codebase-memory-mcp"));
|
||||||
free(preserved_instructions);
|
free(preserved_instructions);
|
||||||
free(preserved_verify);
|
free(preserved_verify);
|
||||||
free(mcp_after);
|
free(mcp_after);
|
||||||
@@ -7324,8 +7522,8 @@ TEST(cli_registry_omp_relocated_dry_run_is_non_mutating) {
|
|||||||
if (!cbm_mkdtemp(tmpdir))
|
if (!cbm_mkdtemp(tmpdir))
|
||||||
FAIL("cbm_mkdtemp failed");
|
FAIL("cbm_mkdtemp failed");
|
||||||
|
|
||||||
const char *const env_names[] = {"HOME", "PATH", "OMP_PROFILE", "PI_CODING_AGENT_DIR",
|
const char *const env_names[] = {
|
||||||
"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])];
|
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
|
||||||
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
|
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
|
||||||
saved_env[i] = save_test_env(env_names[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();
|
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) {
|
TEST(cli_hook_session_sanitizes_untrusted_project_metadata) {
|
||||||
char tmpdir[256];
|
char tmpdir[256];
|
||||||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-untrusted-project-XXXXXX");
|
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_doc *document = settings ? yyjson_read(settings, strlen(settings), 0) : NULL;
|
||||||
yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL;
|
yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL;
|
||||||
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
|
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
|
||||||
size_t owned = test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") +
|
size_t owned =
|
||||||
test_count_exec_hook(root, "PostToolUse", "Read", binary, "hook-augment") +
|
test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") +
|
||||||
test_count_exec_hook(root, "SubagentStart", "*", 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++) {
|
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
|
||||||
owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary, "hook-augment");
|
owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary, "hook-augment");
|
||||||
}
|
}
|
||||||
@@ -10398,6 +10648,55 @@ TEST(cli_hook_augment_bash_pattern_extractor) {
|
|||||||
|
|
||||||
PASS();
|
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) {
|
TEST(cli_hook_augment_lifecycle_output_contract) {
|
||||||
static const struct {
|
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;
|
yyjson_val *settings_root = settings_doc ? yyjson_doc_get_root(settings_doc) : NULL;
|
||||||
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
|
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
|
||||||
size_t registered =
|
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, "PostToolUse", "Read", binary, "hook-augment") +
|
||||||
test_count_exec_hook(settings_root, "SubagentStart", "*", 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++) {
|
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_doc *doc = settings ? yyjson_read(settings, strlen(settings), 0) : NULL;
|
||||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||||
size_t owned =
|
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, "PostToolUse", "Read", binary_path, "hook-augment") +
|
||||||
test_count_exec_hook(root, "SubagentStart", "*", 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++) {
|
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 =
|
size_t before_owned =
|
||||||
test_count_exec_hook(installed_root, "PreToolUse", "Grep|Glob|Bash", binary_path,
|
test_count_exec_hook(installed_root, "PreToolUse", "Grep|Glob|Bash", binary_path,
|
||||||
"hook-augment") +
|
"hook-augment") +
|
||||||
test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path,
|
test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path, "hook-augment") +
|
||||||
"hook-augment") +
|
|
||||||
test_count_exec_hook(installed_root, "SubagentStart", "*", 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++) {
|
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
|
||||||
before_owned += test_count_exec_hook(installed_root, "SessionStart", matchers[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 =
|
yyjson_doc *uninstalled_doc =
|
||||||
uninstalled ? yyjson_read(uninstalled, strlen(uninstalled), 0) : NULL;
|
uninstalled ? yyjson_read(uninstalled, strlen(uninstalled), 0) : NULL;
|
||||||
yyjson_val *uninstalled_root = uninstalled_doc ? yyjson_doc_get_root(uninstalled_doc) : NULL;
|
yyjson_val *uninstalled_root = uninstalled_doc ? yyjson_doc_get_root(uninstalled_doc) : NULL;
|
||||||
size_t after_owned =
|
size_t after_owned = test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\"");
|
||||||
test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\"");
|
size_t foreign_count =
|
||||||
size_t foreign_count = test_count_exec_hook(uninstalled_root, "SessionStart", "resume",
|
test_count_exec_hook(uninstalled_root, "SessionStart", "resume", "foreign-hook", "keep");
|
||||||
"foreign-hook", "keep");
|
|
||||||
yyjson_doc_free(uninstalled_doc);
|
yyjson_doc_free(uninstalled_doc);
|
||||||
free(uninstalled);
|
free(uninstalled);
|
||||||
|
|
||||||
@@ -12568,7 +12867,8 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) {
|
|||||||
test_rmdir_r(tmpdir);
|
test_rmdir_r(tmpdir);
|
||||||
if (install_rc != 0 || before_owned != 7U || uninstall_rc != 0 || after_owned != 0U ||
|
if (install_rc != 0 || before_owned != 7U || uninstall_rc != 0 || after_owned != 0U ||
|
||||||
foreign_count != 1U)
|
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();
|
PASS();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13491,10 +13791,49 @@ TEST(cli_build_args_json_bad_positional_errors_issue680) {
|
|||||||
PASS();
|
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) {
|
TEST(cli_print_tool_help_issue680) {
|
||||||
ASSERT_EQ(cbm_cli_print_tool_help("index_repository"), 0);
|
char out[16 * 1024];
|
||||||
ASSERT_EQ(cbm_cli_print_tool_help("nope_not_a_tool"), -1);
|
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();
|
PASS();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14009,15 +14348,32 @@ TEST(cli_update_only_names_an_installer_that_exists_issue1632) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SUITE(cli) {
|
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);
|
RUN_TEST(cli_update_only_names_an_installer_that_exists_issue1632);
|
||||||
#ifndef _WIN32
|
#ifndef _WIN32
|
||||||
RUN_TEST(cli_hook_deadline_ignores_an_unreadable_value);
|
RUN_TEST(cli_hook_deadline_ignores_an_unreadable_value);
|
||||||
#endif
|
#endif
|
||||||
RUN_TEST(cli_index_restart_cap_honours_zero_and_refuses_junk);
|
RUN_TEST(cli_index_restart_cap_honours_zero_and_refuses_junk);
|
||||||
RUN_TEST(cli_progress_visibility_policy);
|
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_raw_mcp_result_preserves_tool_error_status);
|
||||||
RUN_TEST(cli_maintenance_cancellation_forces_failure_status);
|
RUN_TEST(cli_maintenance_cancellation_forces_failure_status);
|
||||||
RUN_TEST(cli_progress_sink_accepts_worker_json_logs);
|
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_progress_sink_serializes_concurrent_callbacks);
|
||||||
RUN_TEST(cli_sha256_file_matches_known_vector);
|
RUN_TEST(cli_sha256_file_matches_known_vector);
|
||||||
RUN_TEST(cli_checksum_manifest_requires_exact_filename_and_accepts_star);
|
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_installs_session_context_and_subagent);
|
||||||
RUN_TEST(cli_augment_session_uses_workspace_roots);
|
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_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_session_sanitizes_untrusted_project_metadata);
|
||||||
RUN_TEST(cli_hook_ownership_requires_exact_command_identity);
|
RUN_TEST(cli_hook_ownership_requires_exact_command_identity);
|
||||||
RUN_TEST(cli_gemini_hook_upgrade_migrates_released_exact_commands);
|
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_context_tracks_search_json_shape);
|
||||||
RUN_TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter);
|
RUN_TEST(cli_hook_augment_bash_pretooluse_reaches_augmenter);
|
||||||
RUN_TEST(cli_hook_augment_bash_pattern_extractor);
|
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_lifecycle_output_contract);
|
||||||
RUN_TEST(cli_hook_augment_subagent_tier_router_contract);
|
RUN_TEST(cli_hook_augment_subagent_tier_router_contract);
|
||||||
RUN_TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only);
|
RUN_TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only);
|
||||||
@@ -14403,4 +14761,7 @@ SUITE(cli) {
|
|||||||
/* Stdin argument gate (#1359) */
|
/* Stdin argument gate (#1359) */
|
||||||
RUN_TEST(cli_zero_argument_tool_never_reads_stdin_issue1359);
|
RUN_TEST(cli_zero_argument_tool_never_reads_stdin_issue1359);
|
||||||
RUN_TEST(cli_stdin_args_gate_tracks_tool_schema_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
@@ -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
|
/* The discriminator: C has 5 callees, so claiming it has none is a
|
||||||
* fabrication. Pre-fix this is exactly what the saturated path emitted. */
|
* fabrication. Pre-fix this is exactly what the saturated path emitted. */
|
||||||
ASSERT_FALSE(saw_c);
|
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_cypher_result_free(&r);
|
||||||
cbm_store_close(s);
|
cbm_store_close(s);
|
||||||
@@ -1274,6 +1277,9 @@ TEST(cypher_exec_varlength_path_semantics_issue797) {
|
|||||||
ASSERT_EQ(r4.row_count, 0);
|
ASSERT_EQ(r4.row_count, 0);
|
||||||
ASSERT_NOT_NULL(r4.warning);
|
ASSERT_NOT_NULL(r4.warning);
|
||||||
ASSERT_NOT_NULL(strstr(r4.warning, "clamped"));
|
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_cypher_result_free(&r4);
|
||||||
|
|
||||||
cbm_store_close(s);
|
cbm_store_close(s);
|
||||||
@@ -1783,6 +1789,7 @@ TEST(cypher_exec_variable_length) {
|
|||||||
ASSERT_EQ(rc, 0);
|
ASSERT_EQ(rc, 0);
|
||||||
/* Should find: ValidateOrder (1 hop), SubmitOrder (2 hops), LogError (1 hop) */
|
/* Should find: ValidateOrder (1 hop), SubmitOrder (2 hops), LogError (1 hop) */
|
||||||
ASSERT_GTE(r.row_count, 3);
|
ASSERT_GTE(r.row_count, 3);
|
||||||
|
ASSERT_FALSE(r.truncated);
|
||||||
|
|
||||||
cbm_cypher_result_free(&r);
|
cbm_cypher_result_free(&r);
|
||||||
cbm_store_close(s);
|
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_TRUE(saw_n10); /* within the ceiling — proves the traversal really ran */
|
||||||
ASSERT_FALSE(saw_n11); /* clamped away */
|
ASSERT_FALSE(saw_n11); /* clamped away */
|
||||||
ASSERT_FALSE(saw_n12);
|
ASSERT_FALSE(saw_n12);
|
||||||
|
ASSERT_TRUE(r.truncated);
|
||||||
|
|
||||||
cbm_cypher_result_free(&r);
|
cbm_cypher_result_free(&r);
|
||||||
cbm_store_close(s);
|
cbm_store_close(s);
|
||||||
@@ -2143,9 +2151,10 @@ TEST(cypher_optional_match_target_still_allowed_issue1919) {
|
|||||||
TEST(cypher_with_alias_stays_in_scope_issue1919) {
|
TEST(cypher_with_alias_stays_in_scope_issue1919) {
|
||||||
cbm_store_t *s = setup_cypher_store();
|
cbm_store_t *s = setup_cypher_store();
|
||||||
cbm_cypher_result_t r = {0};
|
cbm_cypher_result_t r = {0};
|
||||||
int rc = cbm_cypher_execute(
|
int rc = cbm_cypher_execute(s,
|
||||||
s, "MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller, g AS callee RETURN caller, callee.name",
|
"MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller, g AS "
|
||||||
"test", 0, &r);
|
"callee RETURN caller, callee.name",
|
||||||
|
"test", 0, &r);
|
||||||
ASSERT_EQ(rc, 0);
|
ASSERT_EQ(rc, 0);
|
||||||
ASSERT_NULL(r.error);
|
ASSERT_NULL(r.error);
|
||||||
ASSERT_TRUE(r.row_count > 0);
|
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);
|
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 5", "lim", 0, &r);
|
||||||
ASSERT_EQ(rc, 0);
|
ASSERT_EQ(rc, 0);
|
||||||
ASSERT_EQ(r.row_count, 5);
|
ASSERT_EQ(r.row_count, 5);
|
||||||
|
ASSERT_FALSE(r.truncated);
|
||||||
cbm_cypher_result_free(&r);
|
cbm_cypher_result_free(&r);
|
||||||
|
|
||||||
/* No LIMIT, max_rows=10 → capped at 10 */
|
/* 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);
|
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "lim", 10, &r);
|
||||||
ASSERT_EQ(rc, 0);
|
ASSERT_EQ(rc, 0);
|
||||||
ASSERT_EQ(r.row_count, 10);
|
ASSERT_EQ(r.row_count, 10);
|
||||||
|
ASSERT_TRUE(r.truncated);
|
||||||
cbm_cypher_result_free(&r);
|
cbm_cypher_result_free(&r);
|
||||||
|
|
||||||
/* LIMIT above max_rows → explicit limit wins */
|
/* 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);
|
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 30", "lim", 10, &r);
|
||||||
ASSERT_EQ(rc, 0);
|
ASSERT_EQ(rc, 0);
|
||||||
ASSERT_EQ(r.row_count, 30);
|
ASSERT_EQ(r.row_count, 30);
|
||||||
|
ASSERT_FALSE(r.truncated);
|
||||||
cbm_cypher_result_free(&r);
|
cbm_cypher_result_free(&r);
|
||||||
|
|
||||||
cbm_store_close(s);
|
cbm_store_close(s);
|
||||||
PASS();
|
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
|
* PHASE 1: SIMPLE OPERATORS
|
||||||
* ══════════════════════════════════════════════════════════════════ */
|
* ══════════════════════════════════════════════════════════════════ */
|
||||||
@@ -3036,8 +3178,8 @@ TEST(cypher_wide_with_refused_not_truncated) {
|
|||||||
char query[1024];
|
char query[1024];
|
||||||
int off = snprintf(query, sizeof(query), "MATCH (f:Function) WITH ");
|
int off = snprintf(query, sizeof(query), "MATCH (f:Function) WITH ");
|
||||||
for (int i = 0; i < 20; i++) { /* 20 > CYP_MAX_VARS (16) */
|
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 ? ", " : "",
|
off +=
|
||||||
i);
|
snprintf(query + off, sizeof(query) - (size_t)off, "%sf.name AS c%d", i ? ", " : "", i);
|
||||||
}
|
}
|
||||||
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN *");
|
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_filter_regex);
|
||||||
RUN_TEST(cypher_edge_builtin_type_filter);
|
RUN_TEST(cypher_edge_builtin_type_filter);
|
||||||
RUN_TEST(cypher_apply_limit);
|
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 */
|
/* Phase 1: Simple operators */
|
||||||
RUN_TEST(cypher_lex_neq_operators);
|
RUN_TEST(cypher_lex_neq_operators);
|
||||||
RUN_TEST(cypher_lex_ends_keyword);
|
RUN_TEST(cypher_lex_ends_keyword);
|
||||||
|
|||||||
@@ -2079,6 +2079,8 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) {
|
|||||||
bool second_interrupted = false;
|
bool second_interrupted = false;
|
||||||
bool exited = false;
|
bool exited = false;
|
||||||
atomic_store_explicit(&runtime_activation_shutdown_log_seen, false, memory_order_release);
|
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);
|
cbm_log_set_sink(runtime_test_activation_shutdown_sink);
|
||||||
|
|
||||||
if (started) {
|
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);
|
second_interrupted = !cbm_daemon_runtime_client_heartbeat(second, RUNTIME_TEST_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
cbm_log_set_sink(NULL);
|
cbm_log_set_sink(NULL);
|
||||||
|
cbm_log_set_level(previous_log_level);
|
||||||
if (first) {
|
if (first) {
|
||||||
(void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS);
|
(void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS);
|
||||||
first = NULL;
|
first = NULL;
|
||||||
|
|||||||
@@ -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) {
|
TEST(diagnostics_discovery_record_survives_suppressed_log_level) {
|
||||||
diagnostics_discovery_records = 0;
|
diagnostics_discovery_records = 0;
|
||||||
diagnostics_discovery_line[0] = '\0';
|
diagnostics_discovery_line[0] = '\0';
|
||||||
|
CBMLogLevel previous_log_level = cbm_log_get_level();
|
||||||
cbm_log_set_level(CBM_LOG_NONE);
|
cbm_log_set_level(CBM_LOG_NONE);
|
||||||
cbm_log_set_sink_ex(diagnostics_discovery_sink, CBM_LOG_SINK_REPLACE);
|
cbm_log_set_sink_ex(diagnostics_discovery_sink, CBM_LOG_SINK_REPLACE);
|
||||||
ASSERT_EQ(cbm_setenv("CBM_DIAGNOSTICS", "1", 1), 0);
|
ASSERT_EQ(cbm_setenv("CBM_DIAGNOSTICS", "1", 1), 0);
|
||||||
@@ -369,7 +370,7 @@ TEST(diagnostics_discovery_record_survives_suppressed_log_level) {
|
|||||||
cbm_diag_stop();
|
cbm_diag_stop();
|
||||||
(void)cbm_unsetenv("CBM_DIAGNOSTICS");
|
(void)cbm_unsetenv("CBM_DIAGNOSTICS");
|
||||||
cbm_log_set_sink(NULL);
|
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_snapshot[1024] = "";
|
||||||
char recorded_trajectory[1024] = "";
|
char recorded_trajectory[1024] = "";
|
||||||
|
|||||||
+18
-11
@@ -1172,19 +1172,26 @@ TEST(ui_server_mutations_require_json_content_type) {
|
|||||||
TEST(ui_server_rpc_allows_only_ui_read_tools) {
|
TEST(ui_server_rpc_allows_only_ui_read_tools) {
|
||||||
th_server_t ts;
|
th_server_t ts;
|
||||||
ASSERT_EQ(th_server_start(&ts), 0);
|
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];
|
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];
|
char resp[8192];
|
||||||
int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp));
|
int n = 0;
|
||||||
ASSERT_GT(n, 0);
|
static const char *allowed_tools[] = {"list_projects", "get_graph_schema", "get_code_snippet"};
|
||||||
ASSERT_EQ(th_status(resp), 200);
|
for (size_t i = 0; i < sizeof(allowed_tools) / sizeof(allowed_tools[0]); i++) {
|
||||||
ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\""));
|
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",
|
static const char *blocked_tools[] = {"delete_project", "manage_adr", "ingest_traces",
|
||||||
"index_repository"};
|
"index_repository"};
|
||||||
|
|||||||
+47
-13
@@ -916,6 +916,16 @@ TEST(incr_perf_single_file_fast) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete_file_at("fastapi/incr_perf_probe.py");
|
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();
|
PASS();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,26 +1578,29 @@ TEST(tool_qg_two_hop) {
|
|||||||
|
|
||||||
TEST(tool_qg_max_rows) {
|
TEST(tool_qg_max_rows) {
|
||||||
double ms;
|
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,
|
char *r1 = call_tool_timed("query_graph", &ms,
|
||||||
"{\"project\":\"%s\","
|
"{\"project\":\"%s\","
|
||||||
"\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 100\"}",
|
"\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 100\"}",
|
||||||
g_project);
|
g_project);
|
||||||
TOOL_OK(r1, ms);
|
TOOL_OK(r1, ms);
|
||||||
int total_unlimited = count_in_response(r1, "total");
|
int returned_unlimited = count_in_response(r1, "returned");
|
||||||
free(r1);
|
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,
|
char *r2 = call_tool_timed("query_graph", &ms,
|
||||||
"{\"project\":\"%s\","
|
"{\"project\":\"%s\","
|
||||||
"\"query\":\"MATCH (n:Function) RETURN n.name\","
|
"\"query\":\"MATCH (n:Function) RETURN n.name\","
|
||||||
"\"max_rows\":3}",
|
"\"max_rows\":3}",
|
||||||
g_project);
|
g_project);
|
||||||
TOOL_OK(r2, ms);
|
TOOL_OK(r2, ms);
|
||||||
|
int returned_limited = count_in_response(r2, "returned");
|
||||||
int total_limited = count_in_response(r2, "total");
|
int total_limited = count_in_response(r2, "total");
|
||||||
ASSERT_LTE(total_limited, 3);
|
ASSERT_EQ(returned_limited, 3);
|
||||||
/* Without max_rows should have more than with */
|
ASSERT_GT(total_limited, returned_limited);
|
||||||
ASSERT_GT(total_unlimited, total_limited);
|
ASSERT_GT(returned_unlimited, returned_limited);
|
||||||
|
ASSERT_NOT_NULL(strstr(r2, "page_limit"));
|
||||||
free(r2);
|
free(r2);
|
||||||
PASS();
|
PASS();
|
||||||
}
|
}
|
||||||
@@ -1908,12 +1921,22 @@ TEST(tool_arch_no_aspects) {
|
|||||||
|
|
||||||
TEST(tool_detect_changes_default) {
|
TEST(tool_detect_changes_default) {
|
||||||
double ms;
|
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);
|
TOOL_OK(r, ms);
|
||||||
/* New tree contract: base + direction scalars, a changed_files section,
|
NOT_ERROR(r);
|
||||||
* and the seed/impact accounting. (The old changed_count/impacted_symbols/
|
/* Lean tree contract: preserve exact changed-file accounting, but do not
|
||||||
* depth keys are gone — impact is now a real traversal, not bare names.) */
|
* spend tokens on a zero-row table. A non-empty page still carries the
|
||||||
ASSERT(strstr(r, "changed_files:") != NULL);
|
* 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, "direction:") != NULL);
|
||||||
ASSERT(strstr(r, "seed_symbols:") != NULL);
|
ASSERT(strstr(r, "seed_symbols:") != NULL);
|
||||||
free(r);
|
free(r);
|
||||||
@@ -1961,7 +1984,15 @@ TEST(tool_detect_changes_since) {
|
|||||||
char *r = call_tool_timed("detect_changes", &ms, "{\"project\":\"%s\",\"since\":\"HEAD\"}",
|
char *r = call_tool_timed("detect_changes", &ms, "{\"project\":\"%s\",\"since\":\"HEAD\"}",
|
||||||
g_project);
|
g_project);
|
||||||
TOOL_OK(r, ms);
|
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);
|
free(r);
|
||||||
PASS();
|
PASS();
|
||||||
}
|
}
|
||||||
@@ -2262,8 +2293,11 @@ TEST(tool_qg_max_rows_1) {
|
|||||||
"\"max_rows\":1}",
|
"\"max_rows\":1}",
|
||||||
g_project);
|
g_project);
|
||||||
TOOL_OK(r, ms);
|
TOOL_OK(r, ms);
|
||||||
|
int returned = count_in_response(r, "returned");
|
||||||
int total = count_in_response(r, "total");
|
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);
|
free(r);
|
||||||
PASS();
|
PASS();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ TEST(index_parse_partial_reported) {
|
|||||||
ASSERT_TRUE(marked);
|
ASSERT_TRUE(marked);
|
||||||
|
|
||||||
char qargs[900];
|
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);
|
char *qresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
|
||||||
ASSERT_NOT_NULL(qresp);
|
ASSERT_NOT_NULL(qresp);
|
||||||
ASSERT_NOT_NULL(strstr(qresp, "split.c"));
|
ASSERT_NOT_NULL(strstr(qresp, "split.c"));
|
||||||
@@ -516,7 +516,7 @@ TEST(index_parse_partial_clears_on_fix) {
|
|||||||
|
|
||||||
/* Flagged after the first (full) index. */
|
/* Flagged after the first (full) index. */
|
||||||
char qargs[900];
|
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);
|
char *cov1 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
|
||||||
ASSERT_NOT_NULL(cov1);
|
ASSERT_NOT_NULL(cov1);
|
||||||
ASSERT_NOT_NULL(strstr(cov1, "flaky.c"));
|
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. */
|
/* index_status carries the persisted by-design section. */
|
||||||
char qargs[900];
|
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);
|
char *sresp = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
|
||||||
ASSERT_NOT_NULL(sresp);
|
ASSERT_NOT_NULL(sresp);
|
||||||
ASSERT_NOT_NULL(strstr(sresp, "not_indexed"));
|
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);
|
char *resp2 = cbm_mcp_handle_tool(lp.srv, "index_repository", iargs);
|
||||||
ASSERT_NOT_NULL(resp2);
|
ASSERT_NOT_NULL(resp2);
|
||||||
free(resp2);
|
free(resp2);
|
||||||
|
snprintf(qargs, sizeof(qargs), "{\"project\":\"%s\",\"diagnostics\":\"full\"}", lp.project);
|
||||||
char *sresp2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
|
char *sresp2 = cbm_mcp_handle_tool(lp.srv, "index_status", qargs);
|
||||||
ASSERT_NOT_NULL(sresp2);
|
ASSERT_NOT_NULL(sresp2);
|
||||||
ASSERT_NOT_NULL(strstr(sresp2, "secret.py"));
|
ASSERT_NOT_NULL(strstr(sresp2, "secret.py"));
|
||||||
|
|||||||
@@ -643,6 +643,50 @@ TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling) {
|
|||||||
PASS();
|
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) {
|
TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) {
|
||||||
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
char cache[INDEX_SUPERVISOR_TEST_PATH_CAP];
|
||||||
(void)snprintf(cache, sizeof(cache), "%s/cbm-index-relay-XXXXXX", cbm_tmpdir());
|
(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_async_jobs_are_isolated_cancellable_and_terminal_cached);
|
||||||
RUN_TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree);
|
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_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_drains_terminal_backlog_into_request_progress_callback);
|
||||||
RUN_TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained);
|
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);
|
RUN_TEST(index_supervisor_killed_worker_log_is_never_empty_and_names_the_run);
|
||||||
|
|||||||
@@ -60,6 +60,45 @@ TEST(log_level_default) {
|
|||||||
PASS();
|
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) {
|
TEST(log_level_set) {
|
||||||
cbm_log_set_level(CBM_LOG_WARN);
|
cbm_log_set_level(CBM_LOG_WARN);
|
||||||
ASSERT_EQ(cbm_log_get_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) {
|
SUITE(log) {
|
||||||
RUN_TEST(log_level_default);
|
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_level_set);
|
||||||
RUN_TEST(log_info_output);
|
RUN_TEST(log_info_output);
|
||||||
RUN_TEST(log_filtered_by_level);
|
RUN_TEST(log_filtered_by_level);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ int tf_skip_count = 0;
|
|||||||
#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */
|
#include "foundation/constants.h" /* CBM_SZ_4K — forced stderr buffer */
|
||||||
#include "foundation/log.h" /* crash-durable worker log probe */
|
#include "foundation/log.h" /* crash-durable worker log probe */
|
||||||
#include "foundation/mem.h" /* cbm_mem_init — worker budget */
|
#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 "foundation/platform.h" /* cbm_file_exists — blocking-git marker */
|
||||||
#include "daemon/runtime.h" /* bounded worker response probe */
|
#include "daemon/runtime.h" /* bounded worker response probe */
|
||||||
#include "daemon/ipc.h" /* Windows private-lock re-exec 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);
|
fflush(NULL);
|
||||||
_Exit(response ? 0 : 1);
|
_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\"")) {
|
if (strstr(args_json, "\"crash\"")) {
|
||||||
(void)fprintf(stderr, "async worker crash probe\n");
|
(void)fprintf(stderr, "async worker crash probe\n");
|
||||||
fflush(NULL);
|
fflush(NULL);
|
||||||
@@ -346,6 +358,7 @@ static int tf_maybe_run_index_worker(int argc, char **argv) {
|
|||||||
invocation.marker_file, invocation.quarantine_file,
|
invocation.marker_file, invocation.quarantine_file,
|
||||||
invocation.memory_budget_bytes);
|
invocation.memory_budget_bytes);
|
||||||
cbm_mem_init_with_cap(0.5, 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);
|
tf_index_worker_probe(invocation.args_json, invocation.response_out);
|
||||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||||
if (!srv) {
|
if (!srv) {
|
||||||
|
|||||||
+5918
-73
File diff suppressed because it is too large
Load Diff
+161
-85
@@ -1057,14 +1057,13 @@ static NamedEdgePropertyObservation observe_named_edge_callee_property(
|
|||||||
}
|
}
|
||||||
observation.database_opened = true;
|
observation.database_opened = true;
|
||||||
|
|
||||||
static const char sql[] =
|
static const char sql[] = "SELECT e.properties, json_valid(e.properties), "
|
||||||
"SELECT e.properties, json_valid(e.properties), "
|
"CASE WHEN json_valid(e.properties) "
|
||||||
"CASE WHEN json_valid(e.properties) "
|
"THEN json_extract(e.properties, '$.callee') END "
|
||||||
"THEN json_extract(e.properties, '$.callee') END "
|
"FROM edges e "
|
||||||
"FROM edges e "
|
"JOIN nodes src ON src.id=e.source_id AND src.project=e.project "
|
||||||
"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 "
|
||||||
"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;";
|
||||||
"WHERE e.project=?1 AND e.type=?2 AND src.name=?3 AND tgt.name=?4;";
|
|
||||||
sqlite3_stmt *stmt = NULL;
|
sqlite3_stmt *stmt = NULL;
|
||||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK ||
|
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK ||
|
||||||
sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT) != 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[0] = 'l';
|
||||||
long_reference_name[LONG_REFERENCE_NAME_LEN] = '\0';
|
long_reference_name[LONG_REFERENCE_NAME_LEN] = '\0';
|
||||||
char long_reference_source[1024];
|
char long_reference_source[1024];
|
||||||
int long_reference_source_len = snprintf(
|
int long_reference_source_len =
|
||||||
long_reference_source, sizeof(long_reference_source),
|
snprintf(long_reference_source, sizeof(long_reference_source),
|
||||||
"package parity\n"
|
"package parity\n"
|
||||||
"func %s() {}\n"
|
"func %s() {}\n"
|
||||||
"func longPropertiesReferenceAccept(callback func()) {}\n"
|
"func longPropertiesReferenceAccept(callback func()) {}\n"
|
||||||
"func longPropertiesReferenceSite() { longPropertiesReferenceAccept(%s) }\n",
|
"func longPropertiesReferenceSite() { longPropertiesReferenceAccept(%s) }\n",
|
||||||
long_reference_name, long_reference_name);
|
long_reference_name, long_reference_name);
|
||||||
if (long_reference_source_len <= 0 ||
|
if (long_reference_source_len <= 0 ||
|
||||||
(size_t)long_reference_source_len >= sizeof(long_reference_source)) {
|
(size_t)long_reference_source_len >= sizeof(long_reference_source)) {
|
||||||
th_rmtree(tmp);
|
th_rmtree(tmp);
|
||||||
@@ -1946,18 +1945,18 @@ TEST(pipeline_call_reference_sequential_parallel_edge_set_parity) {
|
|||||||
named_edge_count(sequential_store, sequential_project, "CALLS",
|
named_edge_count(sequential_store, sequential_project, "CALLS",
|
||||||
shadow_controls[i].source_name, shadow_controls[i].target_name);
|
shadow_controls[i].source_name, shadow_controls[i].target_name);
|
||||||
}
|
}
|
||||||
sequential_long_reference = named_edge_count(
|
sequential_long_reference =
|
||||||
sequential_store, sequential_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
|
named_edge_count(sequential_store, sequential_project, "CALL_REFERENCE",
|
||||||
long_reference_name);
|
"longPropertiesReferenceSite", long_reference_name);
|
||||||
sequential_long_usage = named_edge_count(sequential_store, sequential_project, "USAGE",
|
sequential_long_usage =
|
||||||
"longPropertiesReferenceSite",
|
named_edge_count(sequential_store, sequential_project, "USAGE",
|
||||||
long_reference_name);
|
"longPropertiesReferenceSite", long_reference_name);
|
||||||
sequential_long_calls = named_edge_count(sequential_store, sequential_project, "CALLS",
|
sequential_long_calls =
|
||||||
"longPropertiesReferenceSite",
|
named_edge_count(sequential_store, sequential_project, "CALLS",
|
||||||
long_reference_name);
|
"longPropertiesReferenceSite", long_reference_name);
|
||||||
sequential_long_property = observe_named_edge_callee_property(
|
sequential_long_property = observe_named_edge_callee_property(
|
||||||
sequential_db_path, sequential_project, "CALL_REFERENCE",
|
sequential_db_path, sequential_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
|
||||||
"longPropertiesReferenceSite", long_reference_name, long_reference_name);
|
long_reference_name, long_reference_name);
|
||||||
cbm_store_close(sequential_store);
|
cbm_store_close(sequential_store);
|
||||||
}
|
}
|
||||||
cbm_pipeline_free(sequential);
|
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",
|
named_edge_count(parallel_store, parallel_project, "CALLS",
|
||||||
shadow_controls[i].source_name, shadow_controls[i].target_name);
|
shadow_controls[i].source_name, shadow_controls[i].target_name);
|
||||||
}
|
}
|
||||||
parallel_long_reference = named_edge_count(
|
parallel_long_reference =
|
||||||
parallel_store, parallel_project, "CALL_REFERENCE", "longPropertiesReferenceSite",
|
named_edge_count(parallel_store, parallel_project, "CALL_REFERENCE",
|
||||||
long_reference_name);
|
"longPropertiesReferenceSite", long_reference_name);
|
||||||
parallel_long_usage = named_edge_count(parallel_store, parallel_project, "USAGE",
|
parallel_long_usage = named_edge_count(parallel_store, parallel_project, "USAGE",
|
||||||
"longPropertiesReferenceSite", long_reference_name);
|
"longPropertiesReferenceSite", long_reference_name);
|
||||||
parallel_long_calls = named_edge_count(parallel_store, parallel_project, "CALLS",
|
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. */
|
/* 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,
|
static void closure_fresh_full(const char *tmp, const char *db_path, int *out_nodes, int *out_edges,
|
||||||
int *out_edges, int *out_ref_edges, const char *project_hint) {
|
int *out_ref_edges, const char *project_hint) {
|
||||||
*out_nodes = -1;
|
*out_nodes = -1;
|
||||||
*out_edges = -2;
|
*out_edges = -2;
|
||||||
*out_ref_edges = -3;
|
*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) {
|
if (store) {
|
||||||
*out_nodes = cbm_store_count_nodes(store, project);
|
*out_nodes = cbm_store_count_nodes(store, project);
|
||||||
*out_edges = cbm_store_count_edges(store, project);
|
*out_edges = cbm_store_count_edges(store, project);
|
||||||
*out_ref_edges = named_edge_to_file_count(store, project, "CALL_REFERENCE",
|
*out_ref_edges =
|
||||||
"closureProbeCaller", "closureProbeHelper",
|
named_edge_to_file_count(store, project, "CALL_REFERENCE", "closureProbeCaller",
|
||||||
"lib.ts");
|
"closureProbeHelper", "lib.ts");
|
||||||
cbm_store_close(store);
|
cbm_store_close(store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2582,8 +2581,8 @@ TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full) {
|
|||||||
ASSERT_NOT_NULL(store);
|
ASSERT_NOT_NULL(store);
|
||||||
repaired_nodes = cbm_store_count_nodes(store, project);
|
repaired_nodes = cbm_store_count_nodes(store, project);
|
||||||
repaired_edges = cbm_store_count_edges(store, project);
|
repaired_edges = cbm_store_count_edges(store, project);
|
||||||
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE",
|
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE", "closureProbeCaller",
|
||||||
"closureProbeCaller", "closureProbeHelper", "lib.ts");
|
"closureProbeHelper", "lib.ts");
|
||||||
cbm_store_close(store);
|
cbm_store_close(store);
|
||||||
|
|
||||||
char full_db[512];
|
char full_db[512];
|
||||||
@@ -2639,8 +2638,8 @@ TEST(pipeline_closure_repair_removed_def_drops_dependent_edge) {
|
|||||||
int repaired_refs = -1;
|
int repaired_refs = -1;
|
||||||
cbm_store_t *store = cbm_store_open_path(db);
|
cbm_store_t *store = cbm_store_open_path(db);
|
||||||
ASSERT_NOT_NULL(store);
|
ASSERT_NOT_NULL(store);
|
||||||
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE",
|
repaired_refs = named_edge_to_file_count(store, project, "CALL_REFERENCE", "closureProbeCaller",
|
||||||
"closureProbeCaller", "closureProbeHelper", "lib.ts");
|
"closureProbeHelper", "lib.ts");
|
||||||
int repaired_nodes = cbm_store_count_nodes(store, project);
|
int repaired_nodes = cbm_store_count_nodes(store, project);
|
||||||
int repaired_edges = cbm_store_count_edges(store, project);
|
int repaired_edges = cbm_store_count_edges(store, project);
|
||||||
cbm_store_close(store);
|
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
|
* target_a.ts to target_b.ts. Since alias-config governance landed this
|
||||||
* runs as a closure repair, and the convergence assertions below now
|
* runs as a closure repair, and the convergence assertions below now
|
||||||
* prove that route rather than being satisfied by a full rebuild. */
|
* prove that route rather than being satisfied by a full rebuild. */
|
||||||
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(),
|
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
||||||
CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
|
|
||||||
const char *incremental_project = cbm_pipeline_project_name(incremental);
|
const char *incremental_project = cbm_pipeline_project_name(incremental);
|
||||||
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
|
cbm_store_t *incremental_store = cbm_store_open_path(incremental_db);
|
||||||
ASSERT_NOT_NULL(incremental_store);
|
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
|
#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,
|
static void observe_named_generation(const char *db_path, const char *project,
|
||||||
const char *before_name, const char *after_name,
|
const char *before_name, const char *after_name,
|
||||||
int *before_count, int *after_count) {
|
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";
|
static const char canary[] = "canary-must-survive\n";
|
||||||
char canary_path[PREDICTABLE_CANARIES][640];
|
char canary_path[PREDICTABLE_CANARIES][640];
|
||||||
for (int i = 0; i < PREDICTABLE_CANARIES; i++) {
|
for (int i = 0; i < PREDICTABLE_CANARIES; i++) {
|
||||||
snprintf(canary_path[i], sizeof(canary_path[i]), "%s.stage.%ld.%d", db_path,
|
snprintf(canary_path[i], sizeof(canary_path[i]), "%s.stage.%ld.%d", db_path, (long)getpid(),
|
||||||
(long)getpid(), i + 1);
|
i + 1);
|
||||||
ASSERT_EQ(th_write_file(canary_path[i], canary), 0);
|
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;
|
cbm_edge_t *edges = NULL;
|
||||||
int ec = 0;
|
int ec = 0;
|
||||||
ASSERT_EQ(cbm_store_find_edges_by_source_type(s, importer.id, "IMPORTS", &edges, &ec),
|
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;
|
bool found_exact_edge = false;
|
||||||
for (int i = 0; i < ec; i++) {
|
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. */
|
* above for the full end-to-end proof. */
|
||||||
|
|
||||||
TEST(pkgmap_swift_targets_registers_module) {
|
TEST(pkgmap_swift_targets_registers_module) {
|
||||||
static const char src[] =
|
static const char src[] = "// swift-tools-version:5.9\n"
|
||||||
"// swift-tools-version:5.9\n"
|
"import PackageDescription\n"
|
||||||
"import PackageDescription\n"
|
"let package = Package(\n"
|
||||||
"let package = Package(\n"
|
" name: \"Core\",\n"
|
||||||
" name: \"Core\",\n"
|
" targets: [.target(name: \"Core\", dependencies: [])]\n"
|
||||||
" targets: [.target(name: \"Core\", dependencies: [])]\n"
|
")\n";
|
||||||
")\n";
|
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
|
||||||
(int)strlen(src), &entries);
|
&entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||||||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/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";
|
")\n";
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
|
||||||
(int)strlen(src), &entries);
|
&entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_FALSE(pkg_entries_has_name(&entries, "CoreKit"));
|
ASSERT_FALSE(pkg_entries_has_name(&entries, "CoreKit"));
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "CoreImpl"));
|
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
|
* fixture in this file happens to follow `name:` with `dependencies:` or a
|
||||||
* comma, so this specific shape was previously untested and unnoticed. */
|
* comma, so this specific shape was previously untested and unnoticed. */
|
||||||
TEST(pkgmap_swift_target_name_immediately_before_close_paren) {
|
TEST(pkgmap_swift_target_name_immediately_before_close_paren) {
|
||||||
static const char src[] =
|
static const char src[] = "let package = Package(\n"
|
||||||
"let package = Package(\n"
|
" name: \"Core\",\n"
|
||||||
" name: \"Core\",\n"
|
" targets: [.target(name: \"Core\")]\n"
|
||||||
" targets: [.target(name: \"Core\")]\n"
|
")\n";
|
||||||
")\n";
|
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
|
||||||
(int)strlen(src), &entries);
|
&entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||||||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Sources/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";
|
")\n";
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
|
||||||
(int)strlen(src), &entries);
|
&entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
ASSERT_TRUE(pkg_entries_has_name(&entries, "Core"));
|
||||||
ASSERT_STR_EQ(pkg_entries_entry_for(&entries, "Core"), "Core/Vendor/CoreLegacy");
|
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
|
* target entirely (fail closed), even though its `name:` is a valid
|
||||||
* literal. */
|
* literal. */
|
||||||
TEST(pkgmap_swift_target_computed_path_fails_closed) {
|
TEST(pkgmap_swift_target_computed_path_fails_closed) {
|
||||||
static const char src[] =
|
static const char src[] = "let customPath = computePath()\n"
|
||||||
"let customPath = computePath()\n"
|
"let package = Package(\n"
|
||||||
"let package = Package(\n"
|
" name: \"Core\",\n"
|
||||||
" name: \"Core\",\n"
|
" targets: [.target(name: \"Core\", path: customPath)]\n"
|
||||||
" targets: [.target(name: \"Core\", path: customPath)]\n"
|
")\n";
|
||||||
")\n";
|
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src,
|
bool ok = cbm_pkgmap_try_parse("Package.swift", "Core/Package.swift", src, (int)strlen(src),
|
||||||
(int)strlen(src), &entries);
|
&entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_EQ(entries.count, 0);
|
ASSERT_EQ(entries.count, 0);
|
||||||
cbm_pkg_entries_free(&entries);
|
cbm_pkg_entries_free(&entries);
|
||||||
@@ -10392,8 +10468,8 @@ TEST(pkgmap_swift_target_in_comment_or_string_not_registered) {
|
|||||||
")\n";
|
")\n";
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
|
bool ok =
|
||||||
(int)strlen(src), &entries);
|
cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, (int)strlen(src), &entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
||||||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Decoy"));
|
ASSERT_FALSE(pkg_entries_has_name(&entries, "Decoy"));
|
||||||
@@ -10422,8 +10498,8 @@ TEST(pkgmap_swift_dependencies_do_not_leak_entries) {
|
|||||||
")\n";
|
")\n";
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
|
bool ok =
|
||||||
(int)strlen(src), &entries);
|
cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, (int)strlen(src), &entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
||||||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Core"));
|
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
|
* (Utils/UtilsPkg) name OTHER modules, not this manifest's own
|
||||||
* products/targets, so neither mints an entry. */
|
* products/targets, so neither mints an entry. */
|
||||||
TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry) {
|
TEST(pkgmap_swift_target_name_dependency_does_not_leak_entry) {
|
||||||
static const char src[] =
|
static const char src[] = "let package = Package(\n"
|
||||||
"let package = Package(\n"
|
" name: \"App\",\n"
|
||||||
" name: \"App\",\n"
|
" targets: [.target(name: \"App\", dependencies: [\n"
|
||||||
" targets: [.target(name: \"App\", dependencies: [\n"
|
" \"Core\",\n"
|
||||||
" \"Core\",\n"
|
" .product(name: \"Utils\", package: \"UtilsPkg\")\n"
|
||||||
" .product(name: \"Utils\", package: \"UtilsPkg\")\n"
|
" ])]\n"
|
||||||
" ])]\n"
|
")\n";
|
||||||
")\n";
|
|
||||||
cbm_pkg_entries_t entries;
|
cbm_pkg_entries_t entries;
|
||||||
cbm_pkg_entries_init(&entries);
|
cbm_pkg_entries_init(&entries);
|
||||||
bool ok = cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src,
|
bool ok =
|
||||||
(int)strlen(src), &entries);
|
cbm_pkgmap_try_parse("Package.swift", "App/Package.swift", src, (int)strlen(src), &entries);
|
||||||
ASSERT_TRUE(ok);
|
ASSERT_TRUE(ok);
|
||||||
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
ASSERT_TRUE(pkg_entries_has_name(&entries, "App"));
|
||||||
ASSERT_FALSE(pkg_entries_has_name(&entries, "Core"));
|
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_closure_repair_budget_declines_to_full);
|
||||||
RUN_TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full);
|
RUN_TEST(pipeline_incremental_tsconfig_alias_change_matches_fresh_full);
|
||||||
#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API
|
#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_git_context_change_forces_full_and_refreshes_branch);
|
||||||
RUN_TEST(pipeline_global_extension_config_change_forces_full);
|
RUN_TEST(pipeline_global_extension_config_change_forces_full);
|
||||||
RUN_TEST(pipeline_publication_never_uses_a_predictable_staging_path);
|
RUN_TEST(pipeline_publication_never_uses_a_predictable_staging_path);
|
||||||
|
|||||||
@@ -143,6 +143,35 @@ TEST(store_generation_tracks_mutations) {
|
|||||||
PASS();
|
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
|
/* #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
|
* loud store error, not masquerade as a clean end of results. Counts are
|
||||||
* answered from covering indexes (still correct) while row fetches die at
|
* 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) {
|
SUITE(store_pragmas) {
|
||||||
RUN_TEST(journal_size_limit_bounds_wal_issue1083);
|
RUN_TEST(journal_size_limit_bounds_wal_issue1083);
|
||||||
RUN_TEST(store_generation_tracks_mutations);
|
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(corrupt_page_scan_returns_error_not_truncation);
|
||||||
RUN_TEST(mmap_size_default_when_unset);
|
RUN_TEST(mmap_size_default_when_unset);
|
||||||
RUN_TEST(mmap_size_zero_disables_mmap);
|
RUN_TEST(mmap_size_zero_disables_mmap);
|
||||||
|
|||||||
@@ -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 start`` launches a PERMANENT daemon: it reports a pid, and the
|
||||||
daemon survives its clients (a one-shot ``cli`` command recycles it without
|
daemon survives its clients (a one-shot ``cli`` command recycles it without
|
||||||
printing the cold-start hint, and the daemon is still active afterwards).
|
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.
|
* ``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
|
Every path carries a kill-by-pid backstop so a stuck daemon can never hang the
|
||||||
@@ -24,9 +25,22 @@ import sys
|
|||||||
import tempfile
|
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):
|
def run_cli(binary, cache, args, timeout=60, extra_env=None):
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["CBM_CACHE_DIR"] = cache
|
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:
|
if extra_env:
|
||||||
env.update(extra_env)
|
env.update(extra_env)
|
||||||
return subprocess.run([binary] + args, capture_output=True, timeout=timeout, env=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 = run_cli(binary, cache, ["cli", "list_projects", "{}"])
|
||||||
cold_text = output_text(cold)
|
cold_text = output_text(cold)
|
||||||
if cold.returncode != 0 or "daemon start" not in cold_text:
|
if cold.returncode != 0 or "daemon start" in cold_text:
|
||||||
print("RED: a cold one-shot cli command should succeed and hint at "
|
print("RED: a cold one-shot cli command should succeed without default "
|
||||||
"`daemon start`:\n%s" % cold_text[:400])
|
"startup chatter:\n%s" % cold_text[:400])
|
||||||
return 1
|
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 = run_cli(binary, cache, ["daemon", "start"])
|
||||||
start_text = output_text(start)
|
start_text = output_text(start)
|
||||||
|
|||||||
@@ -332,9 +332,9 @@ def section_crash_recovery(binary, work):
|
|||||||
"(`daemon status` kept reporting it)" % daemon_pid)
|
"(`daemon status` kept reporting it)" % daemon_pid)
|
||||||
return False
|
return False
|
||||||
cold = run_cli(binary, cache, ["cli", "list_projects", "{}"], timeout=90)
|
cold = run_cli(binary, cache, ["cli", "list_projects", "{}"], timeout=90)
|
||||||
if cold.returncode != 0 or "daemon start" not in 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 with the "
|
print("RED: a cold one-shot after the daemon crash should succeed without "
|
||||||
"startup-tax hint:\n%s" % excerpt(out_text(cold)))
|
"default startup chatter:\n%s" % excerpt(out_text(cold)))
|
||||||
return False
|
return False
|
||||||
restart = run_cli(binary, cache, ["daemon", "start"], timeout=60)
|
restart = run_cli(binary, cache, ["daemon", "start"], timeout=60)
|
||||||
second_pid = pid_from(out_text(restart))
|
second_pid = pid_from(out_text(restart))
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ def main():
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
# Control: prove the symbol is indexed and queryable.
|
# 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"]
|
projects = json.loads((lp.stdout or b"").decode("utf-8", "replace"))["projects"]
|
||||||
name = projects[0]["name"]
|
name = projects[0]["name"]
|
||||||
sg = run_cli(binary, cache, ["cli", "search_graph",
|
sg = run_cli(binary, cache, ["cli", "search_graph",
|
||||||
|
|||||||
Reference in New Issue
Block a user